diff --git a/CHANGELOG.md b/CHANGELOG.md index b1a30f90..cf7a29b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ cut into a release: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#changelog). ## Unreleased -Links you put up are downloaded once for everyone, plus What's new and fixes for Windows, the relay and chat. +Links you put up are downloaded once for everyone, quick chat and danmaku in the player, plus What's new and fixes for Windows, the relay and chat. ### Added @@ -17,6 +17,10 @@ Links you put up are downloaded once for everyone, plus What's new and fixes for - Invite finds your internet address, lets you pick it or your home-network address, and says when your router needs the port forwarded - The password goes into an invite link only when you tick "Include the password in the link" - Starsync runs once: a link clicked while it is open goes to the open window +- Quick chat: press Enter in the big player to type a message in a small box at the bottom, without opening the chat panel and without the mouse pointer or the controls popping up; it shares its draft with the chat panel, closes by itself when you stop typing, and subtitles move up out of its way +- Danmaku notifications: chat, joins, leaves and Up next can fly across the top of the picture instead of popping up (Settings → Notifications & sounds → Notifications: Danmaku, off by default), with speed, how many at once, where, opacity, size, boldness, outline and avatars all adjustable and a live preview +- Player panel: ⚙ → Player settings opens a panel at the right of the player that stays open while you watch, with tracks, subtitle size, picture, volume and notification settings +- Subtitles can move up above the controls while they show (Settings → Player → Subtitles above the controls, off by default), and their size can be changed ### Changed diff --git a/README.md b/README.md index ae9cb78d..898d6358 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,26 @@ by default, or Only when paused, or Off; messages always reach the chat, with their sound). Watching alone, nothing pops up or sounds about the party (there is nobody); the app's own notices still show. +**Quick chat.** Enter in the big player opens a small chat box at the +bottom centre without opening the chat panel, also while the video +plays: type (the player's keys wait meanwhile), Enter sends, Esc or an +empty Enter closes it, and it closes by itself after a few seconds +without typing. It is the same draft as the chat panel's field, so a +message started in one is there in the other. What you sent shows for a +moment where the box was (or flies as your own line with danmaku, below); +bottom subtitles move up out of its way and back, top signs stay. The +mouse pointer and the controls stay hidden: typing is not moving the +mouse. + +**Danmaku.** Settings → Notifications & sounds → Over the player → +Notifications: Toasts (default) or Danmaku. With Danmaku, over the big +player chat, joins, leaves, Up next and "starting now" fly right to left +across the top of the picture instead of popping up (problems and errors +still pop up, and everything does while the player is small). The look +is yours to tune there, with a live preview: crossing time, how many at +once, which band of the picture they use, opacity, size, boldness, +outline, avatars, and a frame round your own lines. + **While sitting out or in the background.** Sitting out, only chat, nudges, "starting now" (the video starts for the others) and Up next reach you; joins and leaves too if you turn on Settings → Notifications & @@ -390,15 +410,21 @@ volume and sound set, and every sound with a preview, its own set and a switch); (the version, also at the bottom of every Settings page and in the Settings tab's tooltip; and the credits, e.g. that the Minimal player design is inspired by uosc). -Player settings live in the player too: ⚙ → Player settings, at the -right of its controls, and Settings → Player has the same ones (plus the -controls' layout and the custom shader files). Player design: Standard or -Minimal (below); progress line when hidden (on by default: a thin line at -the bottom of the video while the controls are hidden); upscaling shader -(see below); hardware decoding (auto, off or custom; auto is mpv's -`auto-copy` on Windows, `auto` elsewhere; the env var -`SYNC_RELAY_HWDEC` overrides it); in the player also the subtitle and -audio track. Settings → Player → Controls: time display (elapsed / total +Player settings live in the player too: ⚙ → Player settings opens a +narrow panel at the right edge of the player that stays open while you +watch (the controls hide and show as usual) until its × or Esc. Its +sections fold: tracks (subtitles, audio), subtitles (size, above the +controls), picture (upscaling shader, sharp video, hardware decoding), +sound (volume), notifications (toasts or danmaku and the danmaku look) +and controls (design, progress line). Settings → Player has the same +ones (plus the controls' layout and the custom shader files). Player +design: Standard or Minimal (below); progress line when hidden (on by +default: a thin line at the bottom of the video while the controls are +hidden); upscaling shader (see below); hardware decoding (auto, off or +custom; auto is mpv's `auto-copy` on Windows, `auto` elsewhere; the env +var `SYNC_RELAY_HWDEC` overrides it); subtitle size; subtitles above the +controls (off by default: bottom lines move up while the control bar +shows; the quick chat box always moves them). Settings → Player → Controls: time display (elapsed / total or remaining), controls size (normal or large), hide the controls after 2, 3 or 5 s, and which optional buttons sit in the bar (chapter buttons, subtitles, audio, chat; hidden ones are still in ⚙). Every change @@ -408,8 +434,9 @@ Anywhere in a server (unless you are typing): Space = ready / unready, Tab = chat & activity (Tab never moves between buttons). Anywhere: Ctrl+Shift+B = mark a problem in the session log. Keys while the player fills the window: Left/Right = seek 10 s, Up/Down = volume, Shift+Right = skip 1:30, -PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc = -close the chat, then fullscreen, then shrink the player to mini; from the +PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Enter = +quick chat, Esc = close the player panel, then +the chat, then fullscreen, then shrink the player to mini; from the mini it asks to leave, Ctrl+R = reload video, `<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode, Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off. diff --git a/apps/desktop/lib/src/controls/control_menus.dart b/apps/desktop/lib/src/controls/control_menus.dart index 2d277349..f5e3bb1f 100644 --- a/apps/desktop/lib/src/controls/control_menus.dart +++ b/apps/desktop/lib/src/controls/control_menus.dart @@ -11,7 +11,6 @@ import '../widgets/copy_open.dart'; import 'frame.dart'; import 'model/controls_layout.dart'; import 'model/controls_model.dart'; -import 'player_settings.dart'; // The controls' menus, shared by both skins: the track and chapter lists // and the ⚙ menu, all on the app's menu (`showAppMenu`), anchored on the @@ -129,7 +128,7 @@ Future runControlAction(BuildContext context, PlayerControlsModel model, C case ControlAction.markProblem: await ControlsFrame.holdWhile(context, () => showMarkProblem(context)); case ControlAction.playerSettings: - await showPlayerSettings(context, renderMode: model.renderMode); + model.togglePanel(); case ControlAction.leave: model.leave(); } diff --git a/apps/desktop/lib/src/controls/frame.dart b/apps/desktop/lib/src/controls/frame.dart index 5e1681d0..563d79de 100644 --- a/apps/desktop/lib/src/controls/frame.dart +++ b/apps/desktop/lib/src/controls/frame.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/gestures.dart' show kPrimaryMouseButton; import 'package:flutter/material.dart'; @@ -10,7 +11,10 @@ import 'controls_probe.dart' show ControlsHold; // without any (or the mouse leaving) hides them and the cursor. Holds keep // them up: a seek drag (no hover events arrive), an open menu (a route // takes the mouse), and the session's [ControlsHold] (the mouse on a -// floating pill or card, the Up next popover). +// floating pill or card, the Up next popover). Only real mouse movement +// wakes them: a widget appearing or going under a still pointer (the +// quick chat box) does not, nor does typing. [ControlsFrame.suppress] +// hides them while something takes their place (the quick chat box). class ControlsFrame extends StatefulWidget { /// Draws the controls; [visible] false while they are hidden. @@ -28,6 +32,11 @@ class ControlsFrame extends StatefulWidget { /// Keeps them up while held. final ControlsHold? holdBy; + + /// While true the controls are drawn hidden and [shown] says so; the + /// cursor still follows the mouse. They come back as they would have + /// been once it is false again. + final ValueListenable? suppress; const ControlsFrame({ super.key, required this.builder, @@ -37,6 +46,7 @@ class ControlsFrame extends StatefulWidget { this.hideAfter = const Duration(seconds: 3), this.shown, this.holdBy, + this.suppress, }); /// The frame around [context], for widgets that keep it up (a drag, a @@ -62,16 +72,23 @@ class ControlsFrameState extends State { int _holds = 0; bool _heldBy = false; Timer? _timer; + // Where the mouse was last seen, to tell real movement from a widget + // appearing or going under it. + Offset? _lastPos; - bool get visible => _visible; + bool get _suppressed => widget.suppress?.value ?? false; + + /// The controls are drawn (up and not suppressed). + bool get visible => _visible && !_suppressed; @override void initState() { super.initState(); widget.holdBy?.addListener(_holdByChanged); + widget.suppress?.addListener(_suppressChanged); // Not during this build: an overlay listening to [shown] may be built. WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) widget.shown?.value = true; + if (mounted) widget.shown?.value = visible; }); _hideLater(); } @@ -84,12 +101,17 @@ class ControlsFrameState extends State { widget.holdBy?.addListener(_holdByChanged); _holdByChanged(); } + if (oldWidget.suppress != widget.suppress) { + oldWidget.suppress?.removeListener(_suppressChanged); + widget.suppress?.addListener(_suppressChanged); + } if (oldWidget.hideAfter != widget.hideAfter && _visible) _hideLater(); } @override void dispose() { widget.holdBy?.removeListener(_holdByChanged); + widget.suppress?.removeListener(_suppressChanged); _timer?.cancel(); super.dispose(); } @@ -101,15 +123,29 @@ class ControlsFrameState extends State { hold(on); } + void _suppressChanged() { + if (!mounted) return; + setState(() {}); + widget.shown?.value = visible; + } + /// Show the controls and restart the hide countdown. void wake() { if (!_visible && mounted) { setState(() => _visible = true); - widget.shown?.value = true; + widget.shown?.value = visible; } _hideLater(); } + /// A hover or enter at [pos]: wakes only when the mouse really moved. + void _moved(Offset pos) { + final last = _lastPos; + _lastPos = pos; + if (last != null && (pos - last).distance < 0.5) return; + wake(); + } + /// Keep the controls up while [on] (paired calls). void hold(bool on) { _holds = on ? _holds + 1 : (_holds - 1).clamp(0, 1 << 20); @@ -141,9 +177,10 @@ class ControlsFrameState extends State { Widget build(BuildContext context) { return MouseRegion( cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none, - onEnter: (_) => wake(), - onHover: (_) => wake(), - onExit: (_) { + onEnter: (e) => _moved(e.position), + onHover: (e) => _moved(e.position), + onExit: (e) { + _lastPos = e.position; _timer?.cancel(); _hide(); }, @@ -154,12 +191,12 @@ class ControlsFrameState extends State { Positioned.fill( child: DoubleClickListener( onDoubleClick: widget.onDoubleClick!, - skipTop: _visible ? widget.barTop : 0, - skipBottom: _visible ? widget.barBottom : 0, + skipTop: visible ? widget.barTop : 0, + skipBottom: visible ? widget.barBottom : 0, child: const ColoredBox(color: Color(0x00000000), child: SizedBox.expand()), ), ), - widget.builder(context, _visible), + widget.builder(context, visible), ], ), ); diff --git a/apps/desktop/lib/src/controls/model/controls_model.dart b/apps/desktop/lib/src/controls/model/controls_model.dart index edd4dc0a..15baafd0 100644 --- a/apps/desktop/lib/src/controls/model/controls_model.dart +++ b/apps/desktop/lib/src/controls/model/controls_model.dart @@ -115,6 +115,12 @@ class PlayerControlsModel { /// A menu of the controls is open (the player's Esc closes it). int menusOpen = 0; + /// The player panel docked at the right (⚙ → Player settings; Esc or + /// its × closes it). Stays open while the controls hide and show. + final panelOpen = signal(false); + + void togglePanel() => panelOpen.value = !panelOpen.peek(); + void dispose() => volumeFlash.dispose(); // -------------------------------------------------------------- actions diff --git a/apps/desktop/lib/src/controls/player_panel.dart b/apps/desktop/lib/src/controls/player_panel.dart new file mode 100644 index 00000000..4339a56e --- /dev/null +++ b/apps/desktop/lib/src/controls/player_panel.dart @@ -0,0 +1,454 @@ +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart' show JellyfinStreamType; + +import '../app/app_graph.dart'; +import '../danmaku/danmaku_settings.dart' show DanmakuKnobs, NotifyStyleSelect; +import '../playback/playback_controller.dart' show RenderMode; +import '../shaders/shader_presets.dart'; +import '../shell/mark_problem.dart' show MarkProblemButton; +import '../shell/motion.dart'; +import '../state/settings.dart'; +import '../widgets/app_menu.dart'; +import 'model/controls_prefs.dart'; +import 'player_settings.dart' show applyHwdecLive, applyShaderChoice; +import 'volume.dart' as vol; + +// The player panel (⚙ → Player settings, both skins): a narrow glass +// column docked at the right edge of the full player that stays open +// while the controls hide and show, until its × or Esc. What is useful +// while watching, in collapsible sections: tracks, subtitles, picture, +// sound, notifications (toasts or danmaku, the danmaku look) and the +// controls. Everything applies live and is saved; the rest of Settings → +// Player stays there. + +/// Sections folded by the user, kept for the app's run. +final _folded = {'Notifications', 'Controls'}; + +class PlayerPanel extends StatelessWidget { + static const width = 312.0; + final VoidCallback onClose; + + /// The running player's render mode (shader and sharp video notes). + final ReadonlySignal? renderMode; + + /// The player's volume (0..[vol.maxVolume]) and how to set it; the + /// Sound section only with both. + final ReadonlySignal? volume; + final ValueChanged? onVolume; + const PlayerPanel({super.key, required this.onClose, this.renderMode, this.volume, this.onVolume}); + + @override + Widget build(BuildContext context) { + final graph = AppScope.of(context); + final s = graph.coordinator.settings; + return AppGlass( + key: const ValueKey('player-panel'), + child: SizedBox( + width: width, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 6, 2), + child: Row( + children: [ + const Icon(Icons.tune_rounded, size: 18, color: Colors.white70), + const SizedBox(width: 10), + const Expanded( + child: Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), + ), + IconButton( + key: const ValueKey('player-panel-close'), + tooltip: 'Close (Esc)', + visualDensity: VisualDensity.compact, + iconSize: 18, + onPressed: onClose, + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _Section(title: 'Tracks', children: [ + _TrackRow(type: JellyfinStreamType.subtitle), + _TrackRow(type: JellyfinStreamType.audio), + ]), + _Section(title: 'Subtitles', children: [ + SignalBuilder( + builder: (context) => _SliderRow( + key: const ValueKey('panel-sub-scale'), + label: 'Size', + value: s.subScale.value, + min: 0.5, + max: 2, + divisions: 30, + readout: '${(s.subScale.value * 100).round()} %', + onChanged: s.setSubScale, + ), + ), + SignalBuilder( + builder: (context) => _SwitchRow( + key: const ValueKey('panel-lift'), + label: 'Above the controls', + help: 'Bottom lines move up while the control bar shows (the chat box always lifts them)', + value: s.liftSubtitles.value, + onChanged: s.setLiftSubtitles, + ), + ), + ]), + _Section(title: 'Picture', children: [ + _ShaderRow(renderMode: renderMode), + SignalBuilder(builder: (context) { + final m = renderMode?.value; + final software = m == RenderMode.software || m == RenderMode.softwareGl; + return _SwitchRow( + key: const ValueKey('panel-sharp'), + label: 'Sharp video and subtitles', + help: software ? 'No effect: software rendering' : "At your screen's resolution", + value: s.screenResVideo.value, + onChanged: s.setScreenResVideo, + ); + }), + SignalBuilder( + builder: (context) => _Row( + label: 'Hardware decoding', + control: AppSelect( + key: const ValueKey('panel-hwdec'), + value: s.hwdecMode.value, + items: const [(HwdecMode.auto, 'Auto'), (HwdecMode.off, 'Off'), (HwdecMode.custom, 'Custom')], + onChanged: (m) { + s.setHwdecMode(m); + applyHwdecLive(graph.coordinator.playback); + }, + ), + ), + ), + ]), + if (volume case final v?) + _Section(title: 'Sound', children: [ + SignalBuilder( + builder: (context) => _SliderRow( + key: const ValueKey('panel-volume'), + label: 'Volume', + value: v.value, + min: 0, + max: vol.maxVolume, + readout: '${v.value.round()} %', + onChanged: onVolume, + ), + ), + ]), + _Section(title: 'Notifications', children: [ + _Row(label: 'Show as', control: NotifyStyleSelect(settings: s)), + SignalBuilder( + builder: (context) => AnimatedSize( + duration: easeTime, + curve: ease, + alignment: Alignment.topCenter, + child: s.notifyStyle.value == NotifyStyle.danmaku + ? DanmakuKnobs(settings: s, dense: true) + : const SizedBox(width: double.infinity), + ), + ), + ]), + _Section(title: 'Controls', children: [ + SignalBuilder( + builder: (context) => _Row( + label: 'Design', + control: AppSelect( + key: const ValueKey('panel-design'), + value: s.controlsSkin.value, + items: [for (final k in ControlsSkin.values) (k, k.label)], + onChanged: s.setControlsSkin, + ), + ), + ), + SignalBuilder( + builder: (context) => _SwitchRow( + key: const ValueKey('panel-progress-line'), + label: 'Progress line when hidden', + value: s.progressLine.value, + onChanged: s.setProgressLine, + ), + ), + ]), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: Text( + 'More in Settings → Player', + style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12), + ), + ), + const MarkProblemButton(), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +/// A titled group that folds (its title is the toggle). +class _Section extends StatefulWidget { + final String title; + final List children; + const _Section({required this.title, required this.children}); + + @override + State<_Section> createState() => _SectionState(); +} + +class _SectionState extends State<_Section> { + late bool _open = !_folded.contains(widget.title); + + void _toggle() { + setState(() => _open = !_open); + _open ? _folded.remove(widget.title) : _folded.add(widget.title); + } + + @override + Widget build(BuildContext context) { + final muted = Colors.white.withValues(alpha: 0.55); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InkWell( + key: ValueKey('panel-section-${widget.title}'), + borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius), + onTap: _toggle, + child: Padding( + padding: const EdgeInsets.only(top: 10, bottom: 4), + child: Row( + children: [ + Expanded( + child: Text(widget.title.toUpperCase(), + style: TextStyle(fontSize: 11, letterSpacing: 0.8, fontWeight: FontWeight.w600, color: muted)), + ), + AnimatedRotation( + turns: _open ? 0 : -0.25, + duration: easeTime, + curve: ease, + child: Icon(Icons.expand_more_rounded, size: 18, color: muted), + ), + ], + ), + ), + ), + AnimatedSize( + duration: easeTime, + curve: ease, + alignment: Alignment.topCenter, + child: _open + ? Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: widget.children) + : const SizedBox(width: double.infinity), + ), + ], + ); + } +} + +class _Row extends StatelessWidget { + final String label; + final Widget control; + const _Row({required this.label, required this.control}); + + @override + Widget build(BuildContext context) => SizedBox( + height: 40, + child: Row( + children: [ + Expanded(child: Text(label, style: const TextStyle(fontSize: 13))), + Flexible(flex: 2, child: Align(alignment: Alignment.centerRight, child: control)), + ], + ), + ); +} + +class _SwitchRow extends StatelessWidget { + final String label; + final String? help; + final bool value; + final ValueChanged onChanged; + const _SwitchRow({super.key, required this.label, this.help, required this.value, required this.onChanged}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontSize: 13)), + if (help != null) + Text(help!, style: TextStyle(fontSize: 11.5, color: Colors.white.withValues(alpha: 0.5))), + ], + ), + ), + Transform.scale( + scale: 0.8, + alignment: Alignment.centerRight, + child: Switch(value: value, onChanged: onChanged), + ), + ], + ), + ); +} + +class _SliderRow extends StatelessWidget { + final String label; + final double value, min, max; + final int? divisions; + final String readout; + final ValueChanged? onChanged; + const _SliderRow({ + super.key, + required this.label, + required this.value, + required this.min, + required this.max, + this.divisions, + required this.readout, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) => SizedBox( + height: 38, + child: Row( + children: [ + SizedBox(width: 84, 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: 7), + tickMarkShape: SliderTickMarkShape.noTickMark, + ), + child: Slider(value: value.clamp(min, max), min: min, max: max, divisions: divisions, onChanged: onChanged), + ), + ), + SizedBox( + width: 52, + child: Text(readout, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 12, + color: Colors.white.withValues(alpha: 0.7), + fontFeatures: const [FontFeature.tabularFigures()], + )), + ), + ], + ), + ); +} + +/// Subtitle or audio track of the running player; remembered for the +/// next episode. +class _TrackRow extends StatelessWidget { + final JellyfinStreamType type; + const _TrackRow({required this.type}); + + bool get _audio => type == JellyfinStreamType.audio; + + @override + Widget build(BuildContext context) { + final coord = AppScope.of(context).coordinator; + return SignalBuilder(builder: (context) { + final list = [for (final t in coord.playback.tracks.value) if (t.type == type) t]; + final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1); + final items = <(int, String)>[ + if (!_audio) (-1, 'Off'), + for (final t in list) (t.id, t.label), + ]; + final key = ValueKey(_audio ? 'panel-audio' : 'panel-subtitles'); + return _Row( + label: _audio ? 'Audio' : 'Subtitles', + control: items.isEmpty || selected == null + ? Text(items.isEmpty ? 'None' : 'Pick one…', key: key, style: const TextStyle(fontSize: 13)) + : AppSelect( + key: key, + value: selected, + expanded: true, + items: items, + onChanged: (id) { + coord.markInput(); + if (id == -1) { + coord.selectTrack(subtitlesOff: true); + } else if (_audio) { + coord.selectTrack(aid: id); + } else { + coord.selectTrack(sid: id); + } + }, + ), + ); + }); + } +} + +/// The upscaling shader: Anime4K mode (or custom files) and HQ / Fast. +class _ShaderRow extends StatelessWidget { + final ReadonlySignal? renderMode; + const _ShaderRow({this.renderMode}); + + @override + Widget build(BuildContext context) { + final coord = AppScope.of(context).coordinator; + final s = coord.settings; + return SignalBuilder(builder: (context) { + final preset = s.shaderPreset.value; + final hasCustom = s.customShaders.value.isNotEmpty; + final software = switch (renderMode?.value) { + RenderMode.software || RenderMode.softwareGl => true, + _ => false, + }; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Row( + label: 'Upscaling', + control: AppSelect( + key: const ValueKey('panel-shader'), + value: preset, + items: [ + for (final p in ShaderPreset.values) + if (p != ShaderPreset.custom || hasCustom || preset == p) (p, p.label), + ], + onChanged: (p) => applyShaderChoice(coord, p, null), + ), + ), + if (preset.isAnime4k) + _Row( + label: 'Shader quality', + control: AppSelect( + key: const ValueKey('panel-shader-quality'), + value: s.shaderQuality.value, + items: [for (final q in ShaderQuality.values) (q, q.label)], + onChanged: (q) => applyShaderChoice(coord, preset, q), + ), + ), + if (software && preset != ShaderPreset.off) + const Text('Software rendering: shaders have little or no effect', + style: TextStyle(fontSize: 11.5, color: Colors.orange)), + ], + ); + }); + } +} diff --git a/apps/desktop/lib/src/controls/player_settings.dart b/apps/desktop/lib/src/controls/player_settings.dart index 1baefca0..bf613407 100644 --- a/apps/desktop/lib/src/controls/player_settings.dart +++ b/apps/desktop/lib/src/controls/player_settings.dart @@ -2,23 +2,19 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; -import 'package:sync_relay_core/core.dart' show JellyfinStreamType; -import '../jellyfin/track_prefs.dart' show MpvTrack; import '../playback/playback_controller.dart' show PlaybackController, RenderMode; import '../session/session_coordinator.dart' show SessionCoordinator; import '../shaders/shader_presets.dart'; import '../app/app_graph.dart'; -import '../shell/mark_problem.dart'; -import '../shell/motion.dart'; import '../state/settings.dart'; import '../widgets/app_menu.dart'; -import 'frame.dart'; import 'model/controls_prefs.dart'; -// Everything about the player, defined once: the rows of the player -// settings card (⚙ → Player settings, both skins) and of Settings → Player. Each row reads and -// writes [AppSettings] and applies to a running player right away. +// Everything about the player, defined once: the rows of Settings → +// Player (the player panel, `player_panel.dart`, has compact rows for the +// same settings). Each row reads and writes [AppSettings] and applies to +// a running player right away. /// One setting: a label, one line of help under it, the control at the /// right ([below]: an extra line under the row, e.g. a text field). @@ -362,58 +358,6 @@ class _HwdecSettingState extends State { } } -/// Audio or subtitle track of the running player (in the player only). -/// The pick is remembered for the next Jellyfin item. -class TrackSetting extends StatelessWidget { - final JellyfinStreamType type; - const TrackSetting({super.key, required this.type}); - - bool get _audio => type == JellyfinStreamType.audio; - - @override - Widget build(BuildContext context) { - final coord = AppScope.of(context).coordinator; - return SignalBuilder(builder: (context) { - final list = [ - for (final t in coord.playback.tracks.value) - if (t.type == type) t, - ]; - // -1: subtitles off. - final selected = list.where((t) => t.selected).firstOrNull?.id ?? (_audio ? null : -1); - final items = <(int, String)>[ - if (!_audio) (-1, 'Off'), - for (final MpvTrack t in list) (t.id, t.label), - ]; - return SettingRow( - label: _audio ? 'Audio' : 'Subtitles', - help: 'Remembered for the next episode', - control: SizedBox( - width: 190, - child: items.isEmpty || selected == null - ? Text(items.isEmpty ? 'None' : 'Pick one…', - key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'), textAlign: TextAlign.end) - : _dropdown( - key: ValueKey(_audio ? 'audio-track' : 'subtitle-track'), - value: selected, - expanded: true, - items: items, - onChanged: (id) { - coord.markInput(); - if (id == -1) { - coord.selectTrack(subtitlesOff: true); - } else if (_audio) { - coord.selectTrack(aid: id); - } else { - coord.selectTrack(sid: id); - } - }, - ), - ), - ); - }); - } -} - /// The controls' own choices (Settings → Player → Controls). List controlsSettingRows() => [ const TimeDisplaySetting(), @@ -426,58 +370,38 @@ List controlsSettingRows() => [ for (final c in OptionalControl.values) BarButtonSetting(control: c), ]; -/// The player rows of Settings → Player and the player settings card, in -/// order. +/// The player rows of Settings → Player, in order (the player panel has +/// its own compact rows for the same settings). List playerSettingRows({ReadonlySignal? renderMode}) => [ const PlayerDesignSetting(), const ProgressLineSetting(), ShaderPresetSetting(renderMode: renderMode), ScreenResSetting(renderMode: renderMode), const HwdecSetting(), + const SubScaleSetting(), + const LiftSubtitlesSetting(), ]; -/// The player settings card: every player setting, plus the running -/// player's tracks. -class PlayerSettingsCard extends StatelessWidget { - final ReadonlySignal? renderMode; - /// Show the audio / subtitle pickers (a player is running). - final bool tracks; - const PlayerSettingsCard({super.key, this.renderMode, this.tracks = true}); +/// Subtitle size (mpv `sub-scale`, [AppSettings.subScale]). Live. +class SubScaleSetting extends StatelessWidget { + const SubScaleSetting({super.key}); @override Widget build(BuildContext context) { - // The app's glass, like the Up next popover and the menus. - return AppGlass( - key: const ValueKey('player-settings-card'), - child: SizedBox( - width: 420, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 10), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text('Player', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), - const SizedBox(height: 4), - ...playerSettingRows(renderMode: renderMode), - if (tracks) ...[ - const Divider(), - const TrackSetting(type: JellyfinStreamType.subtitle), - const TrackSetting(type: JellyfinStreamType.audio), - ], - const SizedBox(height: 4), - Row( - children: [ - Expanded( - child: Text( - 'Controls layout and custom shaders: Settings → Player', - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12), - ), - ), - const MarkProblemButton(), - ], - ), - ], + final s = AppScope.of(context).coordinator.settings; + return SignalBuilder( + builder: (context) => SettingRow( + label: 'Subtitle size', + help: '${(s.subScale.value * 100).round()} % of what the file sets', + control: SizedBox( + width: 220, + child: Slider( + key: const ValueKey('sub-scale'), + value: s.subScale.value, + min: 0.5, + max: 2, + divisions: 30, + onChanged: s.setSubScale, ), ), ), @@ -485,14 +409,25 @@ class PlayerSettingsCard extends StatelessWidget { } } -/// Opens the player settings card over the player, near the controls' -/// right end; the controls stay up while it is open. -Future showPlayerSettings(BuildContext context, {ReadonlySignal? renderMode}) => - ControlsFrame.holdWhile( - context, - () => showPopover( - context, - at: const Alignment(0.96, 0.5), - child: PlayerSettingsCard(renderMode: renderMode), +/// Bottom subtitles above the control bar while it is up +/// ([AppSettings.liftSubtitles]). Live. +class LiftSubtitlesSetting extends StatelessWidget { + const LiftSubtitlesSetting({super.key}); + + @override + Widget build(BuildContext context) { + final s = AppScope.of(context).coordinator.settings; + return SignalBuilder( + builder: (context) => SettingRow( + label: 'Subtitles above the controls', + help: 'Bottom lines move up while the control bar shows; signs at the top stay. ' + 'The chat box always moves them', + control: Switch( + key: const ValueKey('lift-subtitles'), + value: s.liftSubtitles.value, + onChanged: s.setLiftSubtitles, + ), ), ); + } +} diff --git a/apps/desktop/lib/src/controls/session_controls.dart b/apps/desktop/lib/src/controls/session_controls.dart index d163e7cd..69d52fdb 100644 --- a/apps/desktop/lib/src/controls/session_controls.dart +++ b/apps/desktop/lib/src/controls/session_controls.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; @@ -34,6 +35,9 @@ class SessionControls extends StatelessWidget { final ControlsHold hold; final VoidCallback onDoubleClick; final Widget? windowButtons; + + /// Hides them while something takes their place ([ControlsFrame.suppress]). + final ValueListenable? suppress; const SessionControls({ super.key, required this.model, @@ -41,6 +45,7 @@ class SessionControls extends StatelessWidget { required this.hold, required this.onDoubleClick, this.windowButtons, + this.suppress, }); @override @@ -56,6 +61,7 @@ class SessionControls extends StatelessWidget { key: ValueKey(skin), shown: shown, holdBy: hold, + suppress: suppress, hideAfter: Duration(seconds: s.controlsHideSecs.value), onDoubleClick: onDoubleClick, barTop: controlsTopHeight(skin), diff --git a/apps/desktop/lib/src/danmaku/danmaku.dart b/apps/desktop/lib/src/danmaku/danmaku.dart index f55b4a3d..bc3d361b 100644 --- a/apps/desktop/lib/src/danmaku/danmaku.dart +++ b/apps/desktop/lib/src/danmaku/danmaku.dart @@ -11,7 +11,9 @@ import '../shell/avatar.dart' show AvatarInfo, StatusAvatar; // 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"). +// the video is paused. The session's notifications use it when the user +// picks "Notifications as danmaku" (`danmaku_notices.dart`); the dev +// preview tries it out (DEVELOPMENT.md "Dev tools"). /// What a line is: a chat message or a short notice. enum DanmakuKind { chat, join, leave, upNext, starting } @@ -32,7 +34,9 @@ class DanmakuItem { {required this.kind, required this.text, this.sender, this.senderColor, this.own = false, this.avatar}); } -/// How lines look and move. +/// How lines look and move. Persisted as [toJson] (Settings → +/// Notifications & sounds); [DanmakuStyle.fromJson] clamps every value to +/// its range ([limits]), so a hand-edited or older box never breaks it. @immutable class DanmakuStyle { /// Seconds a line takes from the right edge until it has left on the @@ -51,6 +55,10 @@ class DanmakuStyle { /// Text size relative to the picture's height ([fontSizeFor]). final double size; + /// Font weight of the text, 400 (regular) to 900 (black); the sender's + /// name is a step bolder. + final int weight; + /// Width of the dark outline round the white text, logical px. final double outline; final bool highlightOwn; @@ -65,11 +73,24 @@ class DanmakuStyle { this.bandBottom = 0.45, this.opacity = 0.92, this.size = 1, + this.weight = 600, this.outline = 1.4, this.highlightOwn = true, this.avatars = true, }); + /// The range of each knob (the settings' sliders use them too). + static const limits = ( + crossSecs: (4.0, 16.0), + maxOnScreen: (1, 40), + band: (0.0, 0.9), + minBand: 0.05, + opacity: (0.3, 1.0), + size: (0.6, 1.8), + weight: (400, 900), + outline: (0.0, 4.0), + ); + DanmakuStyle copyWith({ double? crossSecs, int? maxOnScreen, @@ -77,6 +98,7 @@ class DanmakuStyle { double? bandBottom, double? opacity, double? size, + int? weight, double? outline, bool? highlightOwn, bool? avatars, @@ -88,11 +110,69 @@ class DanmakuStyle { bandBottom: bandBottom ?? this.bandBottom, opacity: opacity ?? this.opacity, size: size ?? this.size, + weight: weight ?? this.weight, outline: outline ?? this.outline, highlightOwn: highlightOwn ?? this.highlightOwn, avatars: avatars ?? this.avatars, ); + Map toJson() => { + 'crossSecs': crossSecs, + 'maxOnScreen': maxOnScreen, + 'bandTop': bandTop, + 'bandBottom': bandBottom, + 'opacity': opacity, + 'size': size, + 'weight': weight, + 'outline': outline, + 'highlightOwn': highlightOwn, + 'avatars': avatars, + }; + + /// [json] as stored ([toJson]); missing or wrong values take the + /// default, out-of-range ones the nearest end of their range. + factory DanmakuStyle.fromJson(Map json) { + const d = DanmakuStyle(); + const l = limits; + double num_(String k, double def, (double, double) r) { + final v = json[k]; + return v is num && v.isFinite ? v.toDouble().clamp(r.$1, r.$2) : def; + } + + int int_(String k, int def, (int, int) r) { + final v = json[k]; + return v is num && v.isFinite ? v.round().clamp(r.$1, r.$2) : def; + } + + bool bool_(String k, bool def) { + final v = json[k]; + return v is bool ? v : def; + } + + var top = num_('bandTop', d.bandTop, l.band); + var bottom = num_('bandBottom', d.bandBottom, l.band); + if (bottom - top < l.minBand) (top, bottom) = (d.bandTop, d.bandBottom); + return DanmakuStyle( + crossSecs: num_('crossSecs', d.crossSecs, l.crossSecs), + maxOnScreen: int_('maxOnScreen', d.maxOnScreen, l.maxOnScreen), + bandTop: top, + bandBottom: bottom, + opacity: num_('opacity', d.opacity, l.opacity), + size: num_('size', d.size, l.size), + weight: (int_('weight', d.weight, l.weight) / 100).round() * 100, + outline: num_('outline', d.outline, l.outline), + highlightOwn: bool_('highlightOwn', d.highlightOwn), + avatars: bool_('avatars', d.avatars), + ); + } + + /// [weight] as a [FontWeight] (400..900 in steps of 100). + FontWeight get fontWeight => FontWeight.values[((weight.clamp(100, 900) / 100).round() - 1)]; + + /// The sender's name: a step bolder than the text. + FontWeight get nameWeight => + FontWeight.values[math.min(FontWeight.values.length - 1, (weight.clamp(100, 900) / 100).round())]; + /// 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; @@ -105,6 +185,24 @@ class DanmakuStyle { final band = (bandBottom - bandTop).clamp(0.0, 1.0) * height; return math.max(1, (band / laneHeightFor(height)).floor()); } + + @override + bool operator ==(Object other) => + other is DanmakuStyle && + other.crossSecs == crossSecs && + other.maxOnScreen == maxOnScreen && + other.bandTop == bandTop && + other.bandBottom == bandBottom && + other.opacity == opacity && + other.size == size && + other.weight == weight && + other.outline == outline && + other.highlightOwn == highlightOwn && + other.avatars == avatars; + + @override + int get hashCode => + Object.hash(crossSecs, maxOnScreen, bandTop, bandBottom, opacity, size, weight, outline, highlightOwn, avatars); } /// One line in flight in [lane], from [start] (seconds on the layer's @@ -286,18 +384,21 @@ class _DanmakuLayerState extends State with SingleTickerProviderSt late final Ticker _ticker = createTicker(_onTick); final _lanes = DanmakuLanes<_Line>(); final _repaint = ValueNotifier(0); + // The layer's clock: [_base] from earlier runs of the ticker, which + // only runs while lines fly or wait (an idle layer asks for no frames). + Duration _base = Duration.zero; Duration _elapsed = Duration.zero; Size _size = Size.zero; var _pending = []; var _shown = >[]; - double get _t => _elapsed.inMicroseconds / 1e6; + double get _t => (_base + _elapsed).inMicroseconds / 1e6; @override void initState() { super.initState(); widget.controller.addListener(_onAdd); - _ticker.start(); + _onAdd(); } @override @@ -325,6 +426,7 @@ class _DanmakuLayerState extends State with SingleTickerProviderSt _pending = []; } _pending.addAll(c._take()); + if (!_ticker.isActive) _ticker.start(); } _Line _build(DanmakuItem item) { @@ -333,7 +435,7 @@ class _DanmakuLayerState extends State with SingleTickerProviderSt final base = TextStyle( fontSize: size, height: 1.2, - fontWeight: FontWeight.w600, + fontWeight: s.fontWeight, color: Colors.white, letterSpacing: 0.2, shadows: const [], @@ -346,7 +448,7 @@ class _DanmakuLayerState extends State with SingleTickerProviderSt TextSpan( text: item.kind == DanmakuKind.chat ? '$name ' : '$name ', style: TextStyle( - fontWeight: FontWeight.w700, + fontWeight: s.nameWeight, color: stroke == null ? (item.senderColor ?? Colors.white) : null, ), ), @@ -421,6 +523,11 @@ class _DanmakuLayerState extends State with SingleTickerProviderSt } else if (now.isNotEmpty) { _repaint.value++; } + if (now.isEmpty && _lanes.waiting == 0 && _pending.isEmpty) { + _base += _elapsed; + _elapsed = Duration.zero; + _ticker.stop(); + } } static bool _sameFlights(List> a, List> b) { diff --git a/apps/desktop/lib/src/danmaku/danmaku_notices.dart b/apps/desktop/lib/src/danmaku/danmaku_notices.dart new file mode 100644 index 00000000..1a622d40 --- /dev/null +++ b/apps/desktop/lib/src/danmaku/danmaku_notices.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; + +import '../session/notifier.dart' show NoticeCategory, ToastItem; +import '../shell/avatar.dart' show AvatarInfo, colorForName; +import 'danmaku.dart'; + +// "Notifications: Danmaku" (Settings → Notifications & sounds): over the +// full player, chat and short notices fly across the picture instead of +// popping up as toasts. Problems, errors and the app's own notices stay +// toasts, and so does everything while the player is small or closed. + +/// Whether [t] flies as a line (when danmaku is the style in use): chat, +/// joins / leaves / lost connection / back, "starting now", and Up next +/// news someone caused (added, changed the video). Nudges, problems, your +/// own state and the app's notices stay toasts. +bool fliesAsDanmaku(ToastItem t) => switch (t.category) { + NoticeCategory.chat || NoticeCategory.people || NoticeCategory.start => true, + NoticeCategory.upNext => t.actor != null && t.actor!.isNotEmpty, + _ => false, + }; + +/// [t] as a line: the sender (their avatar from [avatar], their colour) +/// before what they said or did; "starting now" without one. Null for +/// what does not fly ([fliesAsDanmaku]). +DanmakuItem? danmakuForToast(ToastItem t, {AvatarInfo Function(String name, String id)? avatar}) { + if (!fliesAsDanmaku(t)) return null; + final who = t.actor; + if (who == null || who.isEmpty) { + return DanmakuItem(kind: DanmakuKind.starting, text: '▶ ${t.text}'); + } + final pic = avatar?.call(who, t.actorId) ?? AvatarInfo(name: who, color: colorForName(who)); + final color = pic.color; + if (t.chat && t.text.startsWith('$who: ')) { + return DanmakuItem( + kind: DanmakuKind.chat, + sender: who, + senderColor: color, + text: t.text.substring(who.length + 2), + own: t.own, + avatar: pic); + } + final rest = t.text.startsWith('$who ') ? t.text.substring(who.length + 1) : t.text; + final kind = switch (t.category) { + NoticeCategory.upNext => DanmakuKind.upNext, + _ when rest.startsWith('left') || rest.startsWith('lost') => DanmakuKind.leave, + _ => DanmakuKind.join, + }; + return DanmakuItem(kind: kind, sender: who, senderColor: color, text: rest, avatar: pic); +} + +/// The session's lines over the picture: fresh toasts that fly +/// ([fliesAsDanmaku], and not [skip]) go to [controller] while this is +/// mounted (the player mounts it only while danmaku is the style and the +/// full player is up); what was there before it mounted is old news. +class DanmakuNotices extends StatefulWidget { + final ReadonlySignal> toasts; + final ReadonlySignal style; + final DanmakuController controller; + final AvatarInfo Function(String name, String id)? avatar; + + /// Toasts shown another way already (your own quick chat echo). + final bool Function(ToastItem t)? skip; + const DanmakuNotices({ + super.key, + required this.toasts, + required this.style, + required this.controller, + this.avatar, + this.skip, + }); + + @override + State createState() => _DanmakuNoticesState(); +} + +class _DanmakuNoticesState extends State { + late Set _seen = {for (final t in widget.toasts.peek()) t.id}; + late final void Function() _stop; + + @override + void initState() { + super.initState(); + _stop = effect(() { + final items = widget.toasts.value; + untracked(() { + for (final t in items) { + if (_seen.contains(t.id) || (widget.skip?.call(t) ?? false)) continue; + final line = danmakuForToast(t, avatar: widget.avatar); + if (line != null) widget.controller.add(line); + } + _seen = {for (final t in items) t.id}; + }); + }); + } + + @override + void dispose() { + _stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => SignalBuilder( + builder: (context) => DanmakuLayer( + controller: widget.controller, + style: widget.style.value, + accent: Theme.of(context).colorScheme.primary, + ), + ); +} diff --git a/apps/desktop/lib/src/danmaku/danmaku_preview.dart b/apps/desktop/lib/src/danmaku/danmaku_preview.dart index 1e96e939..bb8bfe47 100644 --- a/apps/desktop/lib/src/danmaku/danmaku_preview.dart +++ b/apps/desktop/lib/src/danmaku/danmaku_preview.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:signals/signals_flutter.dart'; import '../controls/minimal/style.dart'; @@ -10,9 +12,12 @@ 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 '../shell/quick_chat.dart'; +import '../shell/video_view.dart' show SubtitleLift; import '../app/app_graph.dart' show AppScope; import '../widgets/app_menu.dart' show AppGlass, AppMenuStyle; import 'danmaku.dart'; +import 'danmaku_notices.dart' show danmakuForToast; // Dev builds only (Settings → About → Developer): try the danmaku look on // the real player (when something is loaded) or on a stand-in picture, @@ -25,7 +30,7 @@ class DanmakuPreview { /// Shown over the full session player. final active = signal(false); - final style = signal(const DanmakuStyle()); + final Signal style; /// A random stream: lines per minute, lengths from one word to a long /// sentence, CJK and emoji among them. @@ -50,7 +55,9 @@ class DanmakuPreview { Timer? _autoTimer; late final void Function() _stopAuto; - DanmakuPreview() { + /// Starts from [style] (the user's look, Settings → Notifications & + /// sounds); the preview's knobs change only the preview. + DanmakuPreview({DanmakuStyle style = const DanmakuStyle()}) : style = signal(style) { _stopAuto = effect(() { final on = auto.value; autoRate.value; @@ -127,41 +134,15 @@ class DanmakuPreview { 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); + /// A toast of this session as a line, as "Notifications: Danmaku" maps + /// it ([danmakuForToast]); the rest (problems) stay toasts. + DanmakuItem? fromToast(ToastItem t) { 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); + return danmakuForToast(t, avatar: c == null ? null : (name, id) => avatarForName(name, c, id)); } /// Follows [toasts] while [live] is on; returns the stop. - void Function() watchToasts(ReadonlySignal> toasts, {String? selfName}) { + void Function() watchToasts(ReadonlySignal> toasts) { var seen = {for (final t in toasts.peek()) t.id}; return effect(() { final items = toasts.value; @@ -169,7 +150,7 @@ class DanmakuPreview { untracked(() { for (final t in items) { if (!seen.add(t.id) || !on) continue; - final line = fromToast(t, selfName: selfName); + final line = fromToast(t); if (line != null) controller.add(line); } seen = {for (final t in items) t.id}; @@ -209,7 +190,11 @@ class DanmakuPreview { /// picture; lets clicks through. class DanmakuPreviewLayers extends StatelessWidget { final DanmakuPreview preview; - const DanmakuPreviewLayers({super.key, required this.preview}); + + /// The covered bottom the mock dialogue moves above, as the real + /// subtitles do ([SubtitleLift]). + final ValueListenable? lift; + const DanmakuPreviewLayers({super.key, required this.preview, this.lift}); @override Widget build(BuildContext context) { @@ -218,7 +203,7 @@ class DanmakuPreviewLayers extends StatelessWidget { builder: (context) => Stack( fit: StackFit.expand, children: [ - MockSubtitles(top: preview.mockTop.value, bottom: preview.mockBottom.value), + MockSubtitles(top: preview.mockTop.value, bottom: preview.mockBottom.value, lift: lift), DanmakuLayer(controller: preview.controller, style: preview.style.value, accent: accent), ], ), @@ -227,11 +212,14 @@ class DanmakuPreviewLayers extends StatelessWidget { } /// 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. +/// (`\an8`, yellow serif) and two lines of dialogue at the bottom. The +/// dialogue moves up by [lift] (px covered at the bottom) in the same +/// steps as mpv's `sub-pos` moves the real ones; the sign stays. class MockSubtitles extends StatelessWidget { final bool top; final bool bottom; - const MockSubtitles({super.key, required this.top, required this.bottom}); + final ValueListenable? lift; + const MockSubtitles({super.key, required this.top, required this.bottom, this.lift}); static Widget _outlined(String text, TextStyle style, double outline) => Stack( children: [ @@ -276,10 +264,15 @@ class MockSubtitles extends StatelessWidget { ), ), if (bottom) - Positioned( - left: 0, - right: 0, - bottom: h * 0.05, + ValueListenableBuilder( + valueListenable: lift ?? const AlwaysStoppedAnimation(0.0), + builder: (context, up, child) => Positioned( + key: const ValueKey('mock-dialogue'), + left: 0, + right: 0, + bottom: h * 0.05 + up, + child: child!, + ), child: Center( child: _outlined( 'I told you the controls would cover this line.\nThey never do now — or do they?', @@ -299,15 +292,29 @@ class MockSubtitles extends StatelessWidget { /// fields drifting over a dusk gradient, so motion and contrast behind /// the lines are realistic. class DanmakuStandIn extends StatefulWidget { - const DanmakuStandIn({super.key}); + /// False: a still picture (the settings' strip while it rests). + final bool animate; + const DanmakuStandIn({super.key, this.animate = true}); @override State createState() => _DanmakuStandInState(); } class _DanmakuStandInState extends State with SingleTickerProviderStateMixin { - late final AnimationController _clock = - AnimationController(vsync: this, duration: const Duration(seconds: 60))..repeat(); + late final AnimationController _clock = AnimationController(vsync: this, duration: const Duration(seconds: 60)); + + @override + void initState() { + super.initState(); + if (widget.animate) _clock.repeat(); + } + + @override + void didUpdateWidget(DanmakuStandIn old) { + super.didUpdateWidget(old); + if (widget.animate && !_clock.isAnimating) _clock.repeat(); + if (!widget.animate && _clock.isAnimating) _clock.stop(); + } @override void dispose() { @@ -521,6 +528,8 @@ class _DanmakuPanelState extends State { _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('Bold', s.weight.toDouble(), 400, 900, '${s.weight}', + (v) => _style((s) => s.copyWith(weight: (v / 100).round() * 100))), _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))), @@ -679,54 +688,127 @@ Future showDanmakuPreviewPage(BuildContext context, DanmakuPreview preview ), ); -class DanmakuPreviewPage extends StatelessWidget { +class DanmakuPreviewPage extends StatefulWidget { final DanmakuPreview preview; const DanmakuPreviewPage({super.key, required this.preview}); + @override + State createState() => _DanmakuPreviewPageState(); +} + +/// The page also has the quick chat box (Enter): what you send flies as +/// your own line, and the mock dialogue moves above the box in the lift's +/// steps, as the real subtitles do over the player. +class _DanmakuPreviewPageState extends State { + final _chat = QuickChat(); + final _lift = SubtitleLift(); + final _pageFocus = FocusNode(debugLabel: 'danmaku preview'); + double _boxH = QuickChatBox.height; + + DanmakuPreview get preview => widget.preview; + + @override + void initState() { + super.initState(); + _chat.open.addListener(_retarget); + WidgetsBinding.instance.addPostFrameCallback((_) => _pageFocus.requestFocus()); + } + + @override + void dispose() { + _chat.open.removeListener(_retarget); + _chat.dispose(); + _lift.dispose(); + _pageFocus.dispose(); + super.dispose(); + } + + void _retarget() { + final s = AppScope.of(context).coordinator.settings; + final bar = preview.controlsBand.peek() && s.liftSubtitles.peek() + ? controlsBottomHeight(s.controlsSkin.peek(), s.controlsSize.peek()) + 8 + : 0.0; + final box = _chat.open.value ? 16 + _boxH + 8 : 0.0; + _lift.animateTo(box > bar ? box : bar); + if (!_chat.open.value) _pageFocus.requestFocus(); + } + + KeyEventResult _key(FocusNode node, KeyEvent e) { + if (e is! KeyDownEvent) return KeyEventResult.ignored; + if (e.logicalKey == LogicalKeyboardKey.enter || e.logicalKey == LogicalKeyboardKey.numpadEnter) { + _chat.show(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + @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], + return Focus( + focusNode: _pageFocus, + onKeyEvent: _key, + child: Material( + color: Colors.black, + child: Stack( + fit: StackFit.expand, + children: [ + const DanmakuStandIn(), + DanmakuPreviewLayers(preview: preview, lift: _lift), + 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: ValueListenableBuilder( + valueListenable: _chat.open, + builder: (context, open, child) => + AnimatedOpacity(opacity: open ? 0 : 1, duration: easeTime, curve: ease, child: child), + 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)), ), - 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, + right: 16, + bottom: 16, + child: QuickChatBox( + chat: _chat, + onSend: (text) => preview.controller.add(preview.own(text)), + onHeight: (h) { + _boxH = h; + if (_chat.open.value) _retarget(); + }, ), - ); - }), - 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()), - ), - ], + ), + Positioned( + left: 16, + top: 14, + child: Text('Stand-in picture: load something to preview on the real player · Enter: chat box', + 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()), + ), + ], + ), ), ); } diff --git a/apps/desktop/lib/src/danmaku/danmaku_settings.dart b/apps/desktop/lib/src/danmaku/danmaku_settings.dart new file mode 100644 index 00000000..a5dcbf53 --- /dev/null +++ b/apps/desktop/lib/src/danmaku/danmaku_settings.dart @@ -0,0 +1,342 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; + +import '../shell/avatar.dart' show AvatarInfo, colorForName, selfAvatarInfo; +import '../state/settings.dart'; +import '../widgets/app_menu.dart' show AppSelect; +import 'danmaku.dart'; +import 'danmaku_preview.dart' show DanmakuPreview, DanmakuStandIn, MockSubtitles; + +// Settings → Notifications & sounds: how notifications show over the full +// player (Toasts or Danmaku) and every knob of the danmaku look, with a +// live preview strip. The player's side panel shows the style and the +// knobs too ([DanmakuKnobs]). + +/// Names of the weights the boldness slider steps through. +const danmakuWeightNames = { + 400: 'Regular', + 500: 'Medium', + 600: 'Semibold', + 700: 'Bold', + 800: 'Extra bold', + 900: 'Black', +}; + +/// "Notifications: Toasts / Danmaku" ([AppSettings.notifyStyle]). +class NotifyStyleSelect extends StatelessWidget { + final AppSettings settings; + const NotifyStyleSelect({super.key, required this.settings}); + + @override + Widget build(BuildContext context) => SignalBuilder( + builder: (context) => AppSelect( + key: const ValueKey('notify-style'), + value: settings.notifyStyle.value, + onChanged: settings.setNotifyStyle, + items: [for (final m in NotifyStyle.values) (m, m.label)], + ), + ); +} + +/// The card: the style picker, the preview strip and the knobs. +class DanmakuSettingsPanel extends StatelessWidget { + final AppSettings settings; + const DanmakuSettingsPanel({super.key, required this.settings}); + + @override + Widget build(BuildContext context) { + final muted = TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Notifications'), + Text( + 'Danmaku: over the full player, chat, joins, leaves, Up next and "starting now" fly across ' + 'the top of the picture instead of popping up. Problems and errors stay pop-ups, and so does ' + 'everything with the player small', + style: muted), + ], + ), + ), + const SizedBox(width: 12), + NotifyStyleSelect(settings: settings), + ], + ), + const SizedBox(height: 12), + DanmakuPreviewStrip(settings: settings), + const SizedBox(height: 6), + DanmakuKnobs(settings: settings), + ], + ); + } +} + +/// A 16:9-ish strip with a moving picture, a top sign and bottom +/// dialogue, and sample lines in the current look, a few a second. +class DanmakuPreviewStrip extends StatefulWidget { + final AppSettings settings; + final double height; + const DanmakuPreviewStrip({super.key, required this.settings, this.height = 190}); + + @override + State createState() => _DanmakuPreviewStripState(); +} + +class _DanmakuPreviewStripState extends State { + /// The strip plays this long after it opens or a knob moves, then + /// rests (a click plays it again): a settings page left open costs + /// nothing. + static const playFor = Duration(seconds: 16); + final _controller = DanmakuController(); + final _rng = math.Random(3); + Timer? _timer; + Timer? _rest; + bool _playing = false; + int _n = 0; + late final void Function() _stop; + + @override + void initState() { + super.initState(); + _stop = effect(() { + widget.settings.danmakuStyle.value; + untracked(_play); + }); + } + + @override + void dispose() { + _stop(); + _timer?.cancel(); + _rest?.cancel(); + _controller.dispose(); + super.dispose(); + } + + void _play() { + _rest?.cancel(); + _rest = Timer(playFor, () { + _timer?.cancel(); + _timer = null; + if (mounted) setState(() => _playing = false); + }); + if (_timer != null) return; + _send(); + _timer = Timer.periodic(const Duration(milliseconds: 1100), (_) => _send()); + if (_playing) return; + _playing = true; + if (_built && mounted) setState(() {}); + } + + bool _built = false; + + void _send() { + final i = _n++; + if (i % 7 == 3) { + final me = selfAvatarInfo(); + _controller.add(DanmakuItem( + kind: DanmakuKind.chat, sender: me.name, senderColor: me.color, text: 'this is my line', own: true, avatar: me)); + return; + } + final name = DanmakuPreview.sampleNames[_rng.nextInt(DanmakuPreview.sampleNames.length)]; + final who = AvatarInfo(name: name, color: colorForName(name)); + if (i % 9 == 5) { + _controller.add(DanmakuItem(kind: DanmakuKind.join, sender: name, senderColor: who.color, text: 'joined', avatar: who)); + return; + } + // Short and medium lines: the long ones crowd a small strip. + final pool = [for (final t in DanmakuPreview.sampleChat) if (t.length < 48) t]; + _controller.add(DanmakuItem( + kind: DanmakuKind.chat, + sender: name, + senderColor: who.color, + text: pool[_rng.nextInt(pool.length)], + avatar: who)); + } + + @override + Widget build(BuildContext context) { + _built = true; + final accent = Theme.of(context).colorScheme.primary; + return ClipRRect( + key: const ValueKey('danmaku-preview-strip'), + borderRadius: BorderRadius.circular(12), + child: SizedBox( + height: widget.height, + child: Stack( + fit: StackFit.expand, + children: [ + DanmakuStandIn(animate: _playing), + const MockSubtitles(top: true, bottom: true), + SignalBuilder( + builder: (context) => + DanmakuLayer(controller: _controller, style: widget.settings.danmakuStyle.value, accent: accent), + ), + Center( + child: AnimatedOpacity( + opacity: _playing ? 0 : 1, + duration: const Duration(milliseconds: 200), + child: IgnorePointer( + ignoring: _playing, + child: FilledButton.tonalIcon( + key: const ValueKey('danmaku-preview-play'), + onPressed: _play, + icon: const Icon(Icons.play_arrow_rounded, size: 18), + label: const Text('Play preview'), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Every knob of the danmaku look, live and saved: crossing time, how +/// many at once, the lane band, opacity, size, boldness, outline, +/// avatars, your own lines framed; and a reset. +class DanmakuKnobs extends StatelessWidget { + final AppSettings settings; + + /// Narrow (the player's side panel): shorter labels column. + final bool dense; + const DanmakuKnobs({super.key, required this.settings, this.dense = false}); + + void _set(DanmakuStyle Function(DanmakuStyle s) f) => settings.setDanmakuStyle(f(settings.danmakuStyle.peek())); + + @override + Widget build(BuildContext context) { + const l = DanmakuStyle.limits; + return SignalBuilder(builder: (context) { + final s = settings.danmakuStyle.value; + final labelW = dense ? 84.0 : 150.0; + Widget slider(String key, String label, double v, (num, num) r, String readout, ValueChanged f, + {int? divisions}) => + _Knob( + label: label, + labelWidth: labelW, + readout: readout, + child: Slider( + key: ValueKey('danmaku-$key'), + value: v.clamp(r.$1.toDouble(), r.$2.toDouble()), + min: r.$1.toDouble(), + max: r.$2.toDouble(), + divisions: divisions, + onChanged: f, + ), + ); + Widget toggle(String key, String label, bool v, ValueChanged f) => _Knob( + label: label, + labelWidth: double.infinity, + child: Switch(key: ValueKey('danmaku-$key'), value: v, onChanged: f), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + slider('cross', dense ? 'Crossing' : 'Crossing time', s.crossSecs, l.crossSecs, + '${s.crossSecs.toStringAsFixed(1)} s', (v) => _set((s) => s.copyWith(crossSecs: v))), + slider('cap', dense ? 'At most' : 'At most on screen', s.maxOnScreen.toDouble(), l.maxOnScreen, + '${s.maxOnScreen} lines', (v) => _set((s) => s.copyWith(maxOnScreen: v.round())), + divisions: l.maxOnScreen.$2 - l.maxOnScreen.$1), + _Knob( + label: dense ? 'Lanes' : 'Lanes (from the top)', + labelWidth: labelW, + readout: '${(s.bandTop * 100).round()}–${(s.bandBottom * 100).round()} %', + child: RangeSlider( + key: const ValueKey('danmaku-band'), + values: RangeValues(s.bandTop, s.bandBottom), + min: l.band.$1, + max: l.band.$2, + onChanged: (v) { + if (v.end - v.start >= l.minBand) _set((s) => s.copyWith(bandTop: v.start, bandBottom: v.end)); + }, + ), + ), + slider('opacity', 'Opacity', s.opacity, l.opacity, '${(s.opacity * 100).round()} %', + (v) => _set((s) => s.copyWith(opacity: v))), + slider('size', 'Size', s.size, l.size, '×${s.size.toStringAsFixed(2)}', (v) => _set((s) => s.copyWith(size: v))), + slider('weight', 'Boldness', s.weight.toDouble(), l.weight, danmakuWeightNames[s.weight] ?? '${s.weight}', + (v) => _set((s) => s.copyWith(weight: (v / 100).round() * 100)), + divisions: (l.weight.$2 - l.weight.$1) ~/ 100), + slider('outline', 'Outline', s.outline, l.outline, + s.outline < 0.05 ? 'none' : '${s.outline.toStringAsFixed(1)} px', (v) => _set((s) => s.copyWith(outline: v))), + toggle('avatars', 'Avatars before the lines', s.avatars, (v) => _set((s) => s.copyWith(avatars: v))), + toggle('own', 'Frame my own lines', s.highlightOwn, (v) => _set((s) => s.copyWith(highlightOwn: v))), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + key: const ValueKey('danmaku-reset'), + onPressed: s == const DanmakuStyle() ? null : () => settings.setDanmakuStyle(const DanmakuStyle()), + icon: const Icon(Icons.restart_alt, size: 18), + label: const Text('Reset look'), + ), + ), + ], + ); + }); + } +} + +class _Knob extends StatelessWidget { + final String label; + final double labelWidth; + final String? readout; + final Widget child; + const _Knob({required this.label, required this.labelWidth, this.readout, required this.child}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final text = Text(label, style: const TextStyle(fontSize: 13)); + if (labelWidth == double.infinity) { + return SizedBox( + height: 38, + child: Row(children: [Expanded(child: text), Transform.scale(scale: 0.8, alignment: Alignment.centerRight, child: child)]), + ); + } + return SizedBox( + height: 36, + child: Row( + children: [ + SizedBox(width: labelWidth, child: text), + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 3, + overlayShape: SliderComponentShape.noOverlay, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), + rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 7), + showValueIndicator: ShowValueIndicator.never, + tickMarkShape: SliderTickMarkShape.noTickMark, + ), + child: child, + ), + ), + SizedBox( + width: 78, + child: Text( + readout ?? '', + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 12, + color: scheme.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/desktop/lib/src/screens/home/about_page.dart b/apps/desktop/lib/src/screens/home/about_page.dart index 727dd1ed..99e4d466 100644 --- a/apps/desktop/lib/src/screens/home/about_page.dart +++ b/apps/desktop/lib/src/screens/home/about_page.dart @@ -27,7 +27,6 @@ class DeveloperPanel extends StatelessWidget { @override Widget build(BuildContext context) { final t = Theme.of(context).textTheme; - final s = AppScope.of(context).coordinator.settings; return SettingsPanel( title: 'Developer', child: Column( @@ -41,19 +40,6 @@ class DeveloperPanel extends StatelessWidget { 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, - ), - ), ], ), ); diff --git a/apps/desktop/lib/src/screens/home/settings_page.dart b/apps/desktop/lib/src/screens/home/settings_page.dart index 240282bd..7d4ccbc9 100644 --- a/apps/desktop/lib/src/screens/home/settings_page.dart +++ b/apps/desktop/lib/src/screens/home/settings_page.dart @@ -16,6 +16,7 @@ const sessionShortcuts = [ ('Shift+→', 'Skip 1:30 (for everyone)'), ('PgUp / PgDn', 'Previous / next chapter'), ('F', 'Toggle fullscreen'), + ('Enter', 'Quick chat box (Enter sends, Esc closes)'), ('Tab', 'Open / close chat & activity'), ('Ctrl+R', 'Reload the video'), ('> or Shift+N', 'Put up the next Jellyfin episode'), diff --git a/apps/desktop/lib/src/session/notifier.dart b/apps/desktop/lib/src/session/notifier.dart index 5ae5241b..e6c6b8a6 100644 --- a/apps/desktop/lib/src/session/notifier.dart +++ b/apps/desktop/lib/src/session/notifier.dart @@ -25,6 +25,11 @@ class ToastItem { final bool chat; /// A picture hash to show (an Up next item's), '' when none. final String thumb; + /// What it is about (the shell may show some of it another way: + /// "Notifications as danmaku"). + final NoticeCategory category; + /// This member's own (their chat message coming back from the server). + final bool own; const ToastItem( {required this.id, required this.text, @@ -32,7 +37,9 @@ class ToastItem { this.actor, this.actorId = '', this.chat = false, - this.thumb = ''}); + this.thumb = '', + this.category = NoticeCategory.app, + this.own = false}); } /// What a notice is about; the policy filters by it ([noticeFor]). @@ -135,6 +142,8 @@ class Notice { final bool chat; /// A picture hash for the toast (an Up next item's). final String thumb; + /// This member is the actor (a feed line of its own). + final bool own; final SfxCue? sound; final bool attention; final SystemNote? system; @@ -146,6 +155,7 @@ class Notice { this.actor, this.chat = false, this.thumb = '', + this.own = false, this.sound, this.attention = false, this.system, @@ -154,12 +164,13 @@ class Notice { static const none = Notice(); - Notice _with({bool toast = true, bool sound = true, SystemNote? system}) => Notice( + Notice _with({bool toast = true, bool sound = true, SystemNote? system, bool own = false}) => Notice( category: category, toast: toast ? this.toast : null, actor: toast ? actor : null, chat: toast && chat, thumb: toast ? thumb : '', + own: own, sound: sound ? this.sound : null, attention: attention, system: system, @@ -334,7 +345,7 @@ Notice noticeFor(AppEvent e, [NoticeContext c = const NoticeContext()]) { payload: cat == NoticeCategory.chat ? SystemNote.payloadChat : '', ); } - return n._with(toast: toast, sound: sound, system: system); + return n._with(toast: toast, sound: sound, system: system, own: own); } /// The one way news reaches the user: [notify] turns an [AppEvent] into @@ -410,7 +421,9 @@ class Notifier { actor: n.actor, actorId: n.actor == null ? '' : n.log?.actorId ?? '', chat: n.chat, - thumb: n.thumb), + thumb: n.thumb, + category: n.category, + own: n.own), ]; _timers[id] = Timer(toastLife, () => dismiss(id)); } diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index 67cc4370..90305289 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_notices.dart' show fliesAsDanmaku; import '../danmaku/danmaku_preview.dart'; import '../files/folder_store.dart'; import '../files/ui/files_page.dart' show showFilesFolder; @@ -38,6 +39,7 @@ import 'profile_card.dart'; import '../schedule/ui/schedule_page.dart'; import 'session_player.dart'; import 'settings_tab.dart'; +import 'quick_chat.dart'; import 'toasts.dart'; import 'welcome.dart'; @@ -112,6 +114,11 @@ class AppShellState extends State with SingleTickerProviderStateMixin /// Whether the full player's controls are up (the player's frame /// reports it): the toasts sit above them. final _controlsShown = ValueNotifier(true); + /// The player's quick chat box (Enter); its draft is the chat panel's. + late final _quickChat = QuickChat(draft: _chat); + /// The player flies notifications as danmaku: those stay out of the + /// toasts. + final _danmakuActive = ValueNotifier(false); Alignment _corner = Alignment.bottomRight; Offset? _drag; // mini top-left while dragging @@ -262,6 +269,8 @@ class AppShellState extends State with SingleTickerProviderStateMixin _flow.dispose(); _join.dispose(); _picker.dispose(); + _quickChat.dispose(); + _danmakuActive.dispose(); _chat.dispose(); _chatFocus.dispose(); _controlsShown.dispose(); @@ -367,7 +376,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin /// 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(); + final d = _danmaku ??= DanmakuPreview(style: _graph.coordinator.settings.danmakuStyle.peek()); if (_online.peek() && _graph.coordinator.playback.alive.peek()) { setState(() => d.active.value = true); _show(PlayerMode.full); @@ -855,6 +864,7 @@ class AppShellState extends State with SingleTickerProviderStateMixin onOpenChat: () { if (!_chatOpen) _toggleChat(); }, + skip: (t) => (_danmakuActive.value && fliesAsDanmaku(t)) || _quickChat.echoed(t), ); return ValueListenableBuilder( valueListenable: _controlsShown, @@ -950,6 +960,8 @@ class AppShellState extends State with SingleTickerProviderStateMixin videoBuilder: widget.videoBuilder, windowControls: widget.windowControls, controlsShown: _controlsShown, + quickChat: _quickChat, + danmakuActive: _danmakuActive, danmaku: _danmaku, ), ), diff --git a/apps/desktop/lib/src/shell/quick_chat.dart b/apps/desktop/lib/src/shell/quick_chat.dart new file mode 100644 index 00000000..c5cc5cdd --- /dev/null +++ b/apps/desktop/lib/src/shell/quick_chat.dart @@ -0,0 +1,397 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +import '../session/notifier.dart' show ToastItem; +import '../widgets/app_menu.dart' show AppMenuStyle; +import 'avatar.dart'; +import 'motion.dart'; + +// The quick chat box: Enter in the full player opens a small box at the +// bottom centre, in the control bar's place, without opening the chat +// panel. It types into the same draft as the chat panel's field. + +/// The box's state, owned by the shell (it outlives the player's modes): +/// open or not, the draft ([draft], the chat panel's controller), its +/// focus, the echo of what was just sent, and the sends whose own chat +/// toast should not show again ([echoed]). +class QuickChat { + /// Closes by itself after this long without typing; the draft stays. + static const idleClose = Duration(seconds: 8); + + /// How long the echo of a sent line stays where the box was. + static const echoTime = Duration(milliseconds: 2600); + + /// The draft, shared with the chat panel's field. + final TextEditingController draft; + final bool _ownsDraft; + final focus = FocusNode(debugLabel: 'quick chat'); + final open = ValueNotifier(false); + + /// The line just sent, shown briefly in the box's place (null: none). + final echo = ValueNotifier(null); + + /// Bumped on each send ([lastSent]): the player flies your own line + /// when danmaku is the notification style (and drops the echo). + final sends = ValueNotifier(0); + String lastSent = ''; + + /// Without typing this long, the box closes ([idleClose]). + final Duration idle; + Timer? _idleTimer; + Timer? _echoTimer; + final _recent = <(String, DateTime)>[]; + + QuickChat({TextEditingController? draft, this.idle = idleClose}) + : draft = draft ?? TextEditingController(), + _ownsDraft = draft == null { + this.draft.addListener(_typed); + } + + void dispose() { + draft.removeListener(_typed); + if (_ownsDraft) draft.dispose(); + _idleTimer?.cancel(); + _echoTimer?.cancel(); + focus.dispose(); + open.dispose(); + echo.dispose(); + sends.dispose(); + } + + /// Opens the box with the draft as it was, caret at its end, and gives + /// it the keyboard at once (keys typed right after go to it). + void show() { + _echoTimer?.cancel(); + echo.value = null; + open.value = true; + draft.selection = TextSelection.collapsed(offset: draft.text.length); + focus.requestFocus(); + _armIdle(); + } + + /// Closes the box; the draft stays for the next Enter (or the panel). + void close() { + _idleTimer?.cancel(); + if (!open.value) return; + open.value = false; + focus.unfocus(); + } + + void _typed() { + if (open.value) _armIdle(); + } + + void _armIdle() { + _idleTimer?.cancel(); + _idleTimer = Timer(idle, close); + } + + /// Enter: the trimmed draft to send (cleared, the box closes and the + /// echo shows it), or null when blank (the box just closes). + String? submit() { + final text = draft.text.trim(); + close(); + if (text.isEmpty) return null; + draft.clear(); + final now = DateTime.now(); + _recent.removeWhere((r) => now.difference(r.$2) > const Duration(seconds: 20)); + _recent.add((text, now)); + lastSent = text; + echo.value = text; + _echoTimer?.cancel(); + _echoTimer = Timer(echoTime, () => echo.value = null); + sends.value++; + return text; + } + + /// [t] is your own chat toast for a line this box already showed (as + /// its echo or your own danmaku line): it should not pop up again. + bool echoed(ToastItem t) { + if (!t.chat || !t.own) return false; + final now = DateTime.now(); + return _recent.any((r) => now.difference(r.$2) < const Duration(seconds: 20) && t.text.endsWith(': ${r.$1}')); + } +} + +/// The box: a glass pill at the bottom centre that starts compact +/// ([minWidth]) and widens with the text up to [maxWidth], then wraps to +/// up to four lines, growing upward. Enter sends ([onSend], empty Enter +/// closes), Esc closes. Your avatar leads it. While closed it stays +/// mounted, invisible and out of the way, so [QuickChat.show] focuses it +/// at once. The pointer over it stays hidden until the mouse moves. +class QuickChatBox extends StatefulWidget { + static const minWidth = 300.0; + static const height = 44.0; + final QuickChat chat; + final double maxWidth; + final void Function(String text) onSend; + + /// Its height whenever it changes (the subtitle lift follows it). + final ValueChanged? onHeight; + const QuickChatBox({super.key, required this.chat, required this.onSend, this.maxWidth = 640, this.onHeight}); + + @override + State createState() => _QuickChatBoxState(); +} + +class _QuickChatBoxState extends State { + static const _text = TextStyle(color: Colors.white, fontSize: 15, height: 1.3); + bool _pointerMoved = false; + Offset? _pointerAt; + + QuickChat get c => widget.chat; + + @override + void initState() { + super.initState(); + c.draft.addListener(_changed); + c.open.addListener(_openChanged); + } + + @override + void didUpdateWidget(QuickChatBox old) { + super.didUpdateWidget(old); + if (old.chat != widget.chat) { + old.chat.draft.removeListener(_changed); + old.chat.open.removeListener(_openChanged); + c.draft.addListener(_changed); + c.open.addListener(_openChanged); + } + } + + @override + void dispose() { + c.draft.removeListener(_changed); + c.open.removeListener(_openChanged); + super.dispose(); + } + + void _changed() => setState(() {}); + + void _openChanged() { + if (c.open.value) _pointerMoved = false; + setState(() {}); + } + + KeyEventResult _key(FocusNode node, KeyEvent e) { + if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored; + final k = e.logicalKey; + if (k == LogicalKeyboardKey.escape) { + c.close(); + return KeyEventResult.handled; + } + if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) { + // An input method composing a word takes Enter itself. + if (c.draft.value.composing.isValid) return KeyEventResult.ignored; + if (e is KeyRepeatEvent) return KeyEventResult.handled; + final text = c.submit(); + if (text != null) widget.onSend(text); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + /// Wide enough for the draft on one line, between [QuickChatBox.minWidth] + /// and the room there is. + double _width(double room) { + final max = math.max(QuickChatBox.minWidth, math.min(widget.maxWidth, room)); + final tp = TextPainter( + text: TextSpan(text: c.draft.text.isEmpty ? ' ' : c.draft.text, style: _text), + textDirection: TextDirection.ltr, + maxLines: 1, + )..layout(); + // Avatar, gaps and padding around the text, plus room for the caret. + final w = tp.width + 22 + 12 + 18 + 18 + 40; + tp.dispose(); + return w.clamp(QuickChatBox.minWidth, max); + } + + @override + Widget build(BuildContext context) { + final open = c.open.value; + final me = selfAvatarInfo(); + return LayoutBuilder(builder: (context, box) { + final width = _width(box.maxWidth); + final field = Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: _key, + child: TextField( + key: const ValueKey('quick-chat-field'), + controller: c.draft, + focusNode: c.focus, + minLines: 1, + maxLines: 4, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.send, + maxLength: 500, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + // Enter is ours (above); a platform send action lands here. + onSubmitted: (_) { + final text = c.submit(); + if (text != null) widget.onSend(text); + }, + mouseCursor: _pointerMoved ? SystemMouseCursors.text : SystemMouseCursors.none, + style: _text, + cursorColor: Colors.white, + cursorWidth: 1.6, + decoration: InputDecoration( + isDense: true, + counterText: '', + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 4), + hintText: 'Say something', + hintStyle: _text.copyWith(color: Colors.white.withValues(alpha: 0.42)), + ), + ), + ); + return Align( + alignment: Alignment.bottomCenter, + child: IgnorePointer( + ignoring: !open, + child: AnimatedSlide( + offset: open ? Offset.zero : const Offset(0, 0.35), + duration: easeTime, + curve: ease, + child: AnimatedOpacity( + opacity: open ? 1 : 0, + duration: open ? easeTime : const Duration(milliseconds: 160), + curve: ease, + child: MouseRegion( + onHover: (e) { + final at = _pointerAt; + _pointerAt = e.position; + if (!_pointerMoved && at != null && (e.position - at).distance > 0.5) { + setState(() => _pointerMoved = true); + } + }, + child: _ReportHeight( + onHeight: widget.onHeight, + child: AnimatedContainer( + key: const ValueKey('quick-chat'), + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + width: width, + constraints: const BoxConstraints(minHeight: QuickChatBox.height), + padding: const EdgeInsets.fromLTRB(11, 6, 16, 6), + decoration: BoxDecoration( + color: AppMenuStyle.glass, + borderRadius: BorderRadius.circular(QuickChatBox.height / 2), + border: Border.all(color: Colors.white.withValues(alpha: open ? 0.22 : 0.1), width: 0.8), + boxShadow: const [BoxShadow(color: Color(0x55000000), blurRadius: 18, offset: Offset(0, 6))], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + StatusAvatar(info: me, size: 22, badges: false, ring: false), + const SizedBox(width: 12), + Expanded(child: field), + const SizedBox(width: 8), + Icon(Icons.keyboard_return_rounded, size: 16, color: Colors.white.withValues(alpha: 0.38)), + ], + ), + ), + ), + ), + ), + ), + ), + ); + }); + } +} + +/// What you just sent, in the box's place for [QuickChat.echoTime]: your +/// avatar and the line on the same glass, then it fades. Shown when the +/// notifications are toasts (with danmaku your own line flies instead). +class QuickChatEcho extends StatelessWidget { + final QuickChat chat; + const QuickChatEcho({super.key, required this.chat}); + + @override + Widget build(BuildContext context) { + final accent = Theme.of(context).colorScheme.primary; + return IgnorePointer( + child: ValueListenableBuilder( + valueListenable: chat.echo, + builder: (context, text, _) => AnimatedSwitcher( + duration: easeTime, + switchInCurve: ease, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, a) => FadeTransition( + opacity: a, + child: SlideTransition( + position: Tween(begin: const Offset(0, 0.25), end: Offset.zero).animate(a), + child: child, + ), + ), + child: text == null + ? const SizedBox.shrink(key: ValueKey('no-echo')) + : Align( + key: ValueKey('echo-$text'), + alignment: Alignment.bottomCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: Container( + key: const ValueKey('quick-chat-echo'), + padding: const EdgeInsets.fromLTRB(11, 10, 16, 10), + decoration: BoxDecoration( + color: AppMenuStyle.glass, + borderRadius: BorderRadius.circular(QuickChatBox.height / 2), + border: Border.all(color: accent.withValues(alpha: 0.7), width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StatusAvatar(info: selfAvatarInfo(), size: 22, badges: false, ring: false), + const SizedBox(width: 12), + Flexible( + child: Text(text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 15, height: 1.3)), + ), + const SizedBox(width: 10), + Icon(Icons.check_rounded, size: 16, color: accent), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +/// Reports its child's height after layout when it changes. +class _ReportHeight extends SingleChildRenderObjectWidget { + final ValueChanged? onHeight; + const _ReportHeight({required this.onHeight, required super.child}); + + @override + RenderObject createRenderObject(BuildContext context) => _RenderReportHeight(onHeight); + + @override + void updateRenderObject(BuildContext context, _RenderReportHeight r) => r.onHeight = onHeight; +} + +class _RenderReportHeight extends RenderProxyBox { + ValueChanged? onHeight; + double? _last; + _RenderReportHeight(this.onHeight); + + @override + void performLayout() { + super.performLayout(); + final h = size.height; + if (h == _last) return; + _last = h; + final f = onHeight; + if (f != null) WidgetsBinding.instance.addPostFrameCallback((_) => f(h)); + } +} diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index 6f76ff8e..96599c43 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -11,13 +11,15 @@ import '../controls/controls_probe.dart'; import '../controls/minimal/style.dart'; import '../controls/model/controls_layout.dart'; import '../controls/model/controls_model.dart'; +import '../controls/player_panel.dart'; import '../controls/session_controls.dart'; import '../controls/volume.dart'; +import '../danmaku/danmaku.dart' show DanmakuController, DanmakuItem, DanmakuKind; +import '../danmaku/danmaku_notices.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; @@ -26,13 +28,13 @@ 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'; import 'motion.dart'; import 'now_playing_title.dart'; import 'player_status.dart'; +import 'quick_chat.dart'; import 'sit_out_card.dart'; import 'start_banner.dart'; import 'video_view.dart'; @@ -48,9 +50,10 @@ typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBu /// The session's player: the one `Video` of the current player and /// everything over it. Full: the controls (one model, the skin picked in -/// Settings), the start card, the status pill, Jellyfin overlays and -/// the player keys. Mini: hover controls and a progress line. The shell keeps this -/// widget mounted at one place in the tree while the mode changes, so the +/// Settings), the start card, the status pill, Jellyfin overlays, the +/// quick chat box, the danmaku, the player panel and the player keys. +/// Mini: hover controls and a progress line. The shell keeps this widget +/// mounted at one place in the tree while the mode changes, so the /// `Video` is never mounted twice nor rebuilt for a move. class SessionPlayer extends StatefulWidget { final PlayerMode mode; @@ -73,6 +76,12 @@ 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; + /// The quick chat box's state (the shell's: its draft is the chat + /// panel's); the player keeps its own when null. + final QuickChat? quickChat; + /// Set while the session's notifications fly as danmaku here (the + /// shell then leaves those out of its toasts). + final ValueNotifier? danmakuActive; /// Dev builds: the danmaku preview, shown over the full player while /// it is active. final DanmakuPreview? danmaku; @@ -93,6 +102,8 @@ class SessionPlayer extends StatefulWidget { this.videoBuilder, this.windowControls = true, this.controlsShown, + this.quickChat, + this.danmakuActive, this.danmaku, }); @@ -108,16 +119,29 @@ class _SessionPlayerState extends State { final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false); DateTime? _lastKeyAt; late final void Function() _stopShaderWatch; + late final void Function() _stopSubScale; + late final void Function() _stopDanmakuWatch; + late final void Function() _stopLiftWatch; int _shaderNoticeSeen = 0; 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 quick chat box (Enter): the shell's, else one of our own. + QuickChat? _ownChat; + QuickChat get _quick => widget.quickChat ?? (_ownChat ??= QuickChat()); + // The box or its echo sits in the control bar's place: the controls + // stay hidden meanwhile. + final _boxUp = ValueNotifier(false); + double _boxHeight = QuickChatBox.height; + // What covers the bottom, as bottom subtitles move above it. + final _lift = SubtitleLift(); + // The Enter that opened the box: kept from the focused widget. + KeyEvent? _swallow; + // The session's danmaku lines (and your own sent ones). + final _lines = DanmakuController(); + final _danmakuOn = signal(false); + late final _full = signal(widget.mode == PlayerMode.full); // The mouse on a floating element, or the Up next popover open, keeps // the controls up. final _hold = ControlsHold(); @@ -139,7 +163,8 @@ class _SessionPlayerState extends State { ); late final SessionCoordinator _coord = AppScope.of(context).coordinator; - bool get _full => widget.mode == PlayerMode.full; + AppSettings get _settings => _coord.settings; + bool get _isFull => widget.mode == PlayerMode.full; @override void initState() { @@ -148,6 +173,7 @@ class _SessionPlayerState extends State { _model; _coord.jellyfin.ensureLoaded(); HardwareKeyboard.instance.addHandler(_globalKey); + FocusManager.instance.addEarlyKeyEventHandler(_earlyKey); // Tab (AppKeys) reaches the chat through here in fullscreen. AppKeys.addChatToggle(_toggleChat); // Shader messages (preset changes, load failures) become toasts. @@ -161,6 +187,32 @@ class _SessionPlayerState extends State { _stopPopoverWatch = effect(() { _hold.set(#upNextPopover, _coord.queue.popoverOpen.value); }); + // Subtitle size on every player, live. + _stopSubScale = effect(() { + final scale = _settings.subScale.value; + _coord.playback.generation.value; + final p = untracked(() => _coord.playback.current); + if (p == null || p.disposed) return; + unawaited(p.setProperty('sub-scale', scale.toStringAsFixed(2)).catchError((Object _) {})); + }); + // Danmaku is the style and the full player is up (not sitting out). + _stopDanmakuWatch = effect(() { + final on = _full.value && _settings.notifyStyle.value == NotifyStyle.danmaku && !_coord.selfAway.value; + untracked(() { + _danmakuOn.value = on; + widget.danmakuActive?.value = on; + }); + }); + _stopLiftWatch = effect(() { + _settings.liftSubtitles.value; + _settings.controlsSkin.value; + _settings.controlsSize.value; + untracked(_retargetLift); + }); + _quick.open.addListener(_boxChanged); + _quick.echo.addListener(_boxChanged); + _quick.sends.addListener(_sent); + _controlsShown.addListener(_retargetLift); } @override @@ -169,19 +221,48 @@ class _SessionPlayerState extends State { _fullscreen.value = widget.fullscreen; _chatOpen.value = widget.chatOpen; _unread.value = widget.unread; + _full.value = _isFull; + if (!_isFull) { + _quick.close(); + _model.panelOpen.value = false; + } + if (oldWidget.quickChat != widget.quickChat) { + final old = oldWidget.quickChat ?? _ownChat; + old?.open.removeListener(_boxChanged); + old?.echo.removeListener(_boxChanged); + old?.sends.removeListener(_sent); + _quick.open.addListener(_boxChanged); + _quick.echo.addListener(_boxChanged); + _quick.sends.addListener(_sent); + } + if (oldWidget.controlsShown != widget.controlsShown) { + (oldWidget.controlsShown ?? _ownShown).removeListener(_retargetLift); + _controlsShown.addListener(_retargetLift); + } } @override void dispose() { HardwareKeyboard.instance.removeHandler(_globalKey); + FocusManager.instance.removeEarlyKeyEventHandler(_earlyKey); AppKeys.removeChatToggle(_toggleChat); _stopShaderWatch(); _stopPopoverWatch(); + _stopSubScale(); + _stopDanmakuWatch(); + _stopLiftWatch(); + widget.danmakuActive?.value = false; + _quick.open.removeListener(_boxChanged); + _quick.echo.removeListener(_boxChanged); + _quick.sends.removeListener(_sent); + _controlsShown.removeListener(_retargetLift); + _quick.close(); + _ownChat?.dispose(); _noFocus.dispose(); _ownShown.dispose(); - _chatLine.dispose(); - _chatLineText.dispose(); - _chatLineFocus.dispose(); + _boxUp.dispose(); + _lift.dispose(); + _lines.dispose(); _hold.dispose(); _model.dispose(); super.dispose(); @@ -189,6 +270,55 @@ class _SessionPlayerState extends State { void _toggleChat() => _model.toggleChat(); + // ------------------------------------------------------- quick chat box + + void _boxChanged() { + _boxUp.value = _quick.open.value || _quick.echo.value != null; + _retargetLift(); + } + + /// What covers the bottom of the full player, for the subtitle lift: + /// the box (or its echo) from 16 px up, else the control bar while it is + /// up when "Subtitles above the controls" is on; 8 px above it. + void _retargetLift() { + if (!mounted) return; + final box = _boxUp.value ? _edge + _boxHeight + 8 : 0.0; + final bar = _settings.liftSubtitles.peek() && _controlsShown.value ? _bottomControlsHeight() + 8 : 0.0; + _lift.animateTo(math.max(box, bar)); + } + + void _boxHeightChanged(double h) { + if (h == _boxHeight) return; + _boxHeight = h; + _retargetLift(); + setState(() {}); + } + + /// Enter in the box with text: to the chat; your own line flies right + /// away when danmaku is the style (the echo is for toasts). + void _send(String text) => _coord.sendChat(text); + + void _sent() { + if (!_danmakuOn.peek()) return; + _quick.echo.value = null; + final me = selfAvatarInfo(); + _lines.add(DanmakuItem( + kind: DanmakuKind.chat, sender: me.name, senderColor: me.color, text: _quick.lastSent, own: true, avatar: me)); + } + + /// Swallows the Enter that opened the box before the focused widget + /// (a button keeps focus after a click) acts on it too. + KeyEventResult _earlyKey(KeyEvent event) { + final s = _swallow; + if (s == null || event.logicalKey != s.logicalKey) return KeyEventResult.ignored; + if (event is KeyDownEvent && !identical(event, s)) { + _swallow = null; + return KeyEventResult.ignored; + } + if (event is KeyUpEvent) _swallow = null; + return KeyEventResult.handled; + } + /// The player keys, while the player fills the body and nothing (a /// dialog, a card, a text field) is in front of it. Space and Tab are /// app-wide ([AppKeys]). @@ -197,7 +327,7 @@ class _SessionPlayerState extends State { if (!AppScope.of(context).navigation.online.peek()) return false; // Mini: Esc is the last step out (a friend's server: asking to leave // it; yours: the player closes). - if (!_full) { + if (!_isFull) { if (event.logicalKey != LogicalKeyboardKey.escape) return false; if (ModalRoute.of(context)?.isCurrent == false || textInputFocused()) return false; widget.onLeave(); @@ -213,10 +343,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(); + final key = event.logicalKey; + // Enter: the quick chat box (the chat panel's draft), whatever plays. + if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) { + if (_coord.selfAway.peek()) return false; + _swallow = event; + _quick.show(); return true; } final now = DateTime.now().toUtc(); @@ -224,7 +356,6 @@ class _SessionPlayerState extends State { return false; } final model = _model; - final key = event.logicalKey; // Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A). final digit = _shaderKeys.indexOf(key); if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) { @@ -276,7 +407,9 @@ class _SessionPlayerState extends State { } if (key == LogicalKeyboardKey.escape) { _lastKeyAt = now; - if (widget.chatOpen) { + if (_model.panelOpen.peek()) { + _model.panelOpen.value = false; + } else if (widget.chatOpen) { widget.onToggleChat(); } else if (widget.fullscreen) { widget.onToggleFullscreen(); @@ -308,7 +441,7 @@ class _SessionPlayerState extends State { if (!playback.alive.value) { return Center( child: Text('player starting…', - style: TextStyle(color: Colors.white70, fontSize: _full ? 14 : 12)), + style: TextStyle(color: Colors.white70, fontSize: _isFull ? 14 : 12)), ); } if (gen != _videoGen) { @@ -317,25 +450,25 @@ class _SessionPlayerState extends State { } // Never null for the mini: media_kit keeps the last controls // when given null, so the full player's would stay under it. - final VideoControlsBuilder controls = _full ? _sessionControls : _noControls; + final VideoControlsBuilder controls = _isFull ? _sessionControls : _noControls; final video = widget.videoBuilder?.call(context, controls) ?? _video(controls); // The wheel anywhere on the full player steps the volume // (lists, menus and popovers under the mouse scroll instead). return VolumeWheel( - enabled: _full, + enabled: _isFull, onStep: _model.stepVolume, child: Stack( fit: StackFit.expand, children: [ video, - // Dev experiment: bottom subtitles above the controls. - if (devTools) _subtitleLifter(), - if (_full) ...[ + SubtitleLifter(playback: _coord.playback, lift: _lift, active: _isFull), + if (_isFull) ...[ _blockedCard(), _actionArea(), if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()], _volumeReadout(), - if (devTools) _chatLineOverlay(), + _quickChatBox(), + _playerPanel(), if (widget.danmaku case final d?) _danmakuPanel(d), ] else _miniOverlay(), @@ -352,7 +485,7 @@ class _SessionPlayerState extends State { Widget _video(VideoControlsBuilder? controls) => VideoOutputSizer( playback: _coord.playback, - sharp: _coord.settings.screenResVideo, + sharp: _settings.screenResVideo, child: Video( key: _videoKey, controller: videoControllerOf(_coord.playback.video), @@ -361,100 +494,56 @@ class _SessionPlayerState extends State { ), ); - /// 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( + /// The quick chat box in the control bar's place, centred (Enter opens + /// it), and the echo of what you sent. + Widget _quickChatBox() => 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), - ), - ), - ), - ], - ), + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + QuickChatEcho(chat: _quick), + QuickChatBox(chat: _quick, onSend: _send, onHeight: _boxHeightChanged), + ], + ), + ); + + /// The player panel docked at the right (⚙ → Player settings), between + /// the top bar and the control bar; it stays while they hide. + Widget _playerPanel() => Positioned( + right: _edge, + top: _topBarHeight + 8, + bottom: _bottomControlsHeightNow() + _edge, + child: SignalBuilder(builder: (context) { + final open = _model.panelOpen.value; + return AnimatedSwitcher( + duration: easeTime, + switchInCurve: ease, + switchOutCurve: Curves.easeIn, + layoutBuilder: (current, previous) => + Stack(alignment: Alignment.topRight, children: [...previous, ?current]), + transitionBuilder: (child, a) => FadeTransition( + opacity: a, + child: SlideTransition( + position: Tween(begin: const Offset(0.12, 0), end: Offset.zero).animate(a), + child: child, ), ), - ), - ), + child: !open + ? const SizedBox.shrink(key: ValueKey('no-player-panel')) + : ControlsHoldRegion( + key: const ValueKey('player-panel-hold'), + hold: _hold, + child: PlayerPanel( + onClose: () => _model.panelOpen.value = false, + renderMode: _model.renderMode, + volume: _model.volume, + onVolume: _model.setVolume, + ), + ), + ); + }), ); /// Fullscreen hides the strip and its window buttons: the top bar @@ -482,24 +571,29 @@ class _SessionPlayerState extends State { children: [ const StartBanner(), // Under the controls, over the picture. + SignalBuilder( + builder: (context) => _danmakuOn.value + ? DanmakuNotices( + toasts: _coord.toasts, + style: _settings.danmakuStyle, + controller: _lines, + avatar: (name, id) => avatarForName(name, _coord, id), + skip: _quick.echoed, + ) + : const SizedBox.shrink(), + ), 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(), + builder: (context) => + d.active.value ? DanmakuPreviewLayers(preview: d, lift: _lift) : const SizedBox.shrink(), ), + SessionControls( + model: _model, + shown: _controlsShown, + hold: _hold, + onDoubleClick: widget.onToggleFullscreen, + windowButtons: _fullscreenWindowButtons(), + suppress: _boxUp, ), ], ); @@ -510,12 +604,13 @@ class _SessionPlayerState extends State { static const _edge = 16.0; /// The bottom controls' height in the skin and size in use. - static double _bottomControlsHeight() { - final s = AppSettings.instance; - return controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value); - } + double _bottomControlsHeight() => + controlsBottomHeight(_settings.controlsSkin.value, _settings.controlsSize.value); - static double get _topBarHeight => controlsTopHeight(AppSettings.instance.controlsSkin.peek()); + double _bottomControlsHeightNow() => + controlsBottomHeight(_settings.controlsSkin.peek(), _settings.controlsSize.peek()); + + double get _topBarHeight => controlsTopHeight(_settings.controlsSkin.peek()); /// The danmaku preview's panel, top right under the top bar; the mouse /// on it keeps the controls up. @@ -560,15 +655,20 @@ class _SessionPlayerState extends State { Widget _actionArea() { return SignalBuilder(builder: (context) { final controls = _bottomControlsHeight(); - return ValueListenableBuilder( - valueListenable: _controlsShown, - builder: (context, shown, child) => AnimatedPositioned( + return ListenableBuilder( + listenable: Listenable.merge([_controlsShown, _boxUp]), + builder: (context, child) => AnimatedPositioned( duration: easeTime, curve: ease, left: _edge, right: _edge, top: 0, - bottom: shown ? controls + _edge : _edge, + // Above the quick chat box while it (or its echo) is up. + bottom: _boxUp.value + ? _boxHeight + _edge * 2 + : _controlsShown.value + ? controls + _edge + : _edge, child: child!, ), child: CustomMultiChildLayout( @@ -705,7 +805,7 @@ class _SessionPlayerState extends State { child: Stack( fit: StackFit.expand, children: [ - SitOutCard(key: const ValueKey('sit-out-card'), compact: !_full), + SitOutCard(key: const ValueKey('sit-out-card'), compact: !_isFull), Positioned( top: 4, right: 4, @@ -714,9 +814,9 @@ class _SessionPlayerState extends State { children: [ IconButton( focusNode: _noFocus, - onPressed: _full ? widget.onCollapse : widget.onExpand, - tooltip: _full ? 'Small player' : 'Expand', - icon: Icon(_full ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18), + onPressed: _isFull ? widget.onCollapse : widget.onExpand, + tooltip: _isFull ? 'Small player' : 'Expand', + icon: Icon(_isFull ? Icons.close_fullscreen : Icons.open_in_full, color: Colors.white, size: 18), ), IconButton( focusNode: _noFocus, diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart index 25aca519..4001a242 100644 --- a/apps/desktop/lib/src/shell/settings_tab.dart +++ b/apps/desktop/lib/src/shell/settings_tab.dart @@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart' show CookieBrowser, CookieFile, NetscapeCookie, YtDlp, YtDlpCookies, cookieBrowsers, parseCookies; import '../controls/player_settings.dart'; +import '../danmaku/danmaku_settings.dart' show DanmakuSettingsPanel; import '../jellyfin/accounts.dart'; import '../jellyfin/accounts_panel.dart'; import '../schedule/animeflow.dart'; @@ -124,7 +125,8 @@ class SettingsTab extends StatelessWidget { return [ const SettingsHintCard( icon: Icons.tune, - text: "Player settings are also in the player: ⚙ → Player settings, at the right of its controls.", + text: "Player settings are also in the player: ⚙ → Player settings opens a panel at its right edge " + "that stays while you watch.", ), SettingsPanel( title: 'Player', @@ -778,7 +780,8 @@ class NotificationsInfo extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ line('Joins, leaves, lost connections and video changes', - "Pop up bottom left for 5 s with the person's picture, above the player's controls"), + "Pop up bottom left for 5 s with the person's picture, above the player's controls " + '(or fly across the picture: Danmaku, below)'), line('Added to Up next', 'With the title, its picture and who added it'), Padding( padding: const EdgeInsets.symmetric(vertical: 6), @@ -811,6 +814,11 @@ class NotificationsInfo extends StatelessWidget { ], ), ), + SettingsPanel( + key: const ValueKey('danmaku-settings'), + title: 'Over the player', + child: DanmakuSettingsPanel(settings: AppScope.of(context).coordinator.settings), + ), SettingsPanel( title: 'Sitting out and in the background', child: Column( diff --git a/apps/desktop/lib/src/shell/toasts.dart b/apps/desktop/lib/src/shell/toasts.dart index c0597a42..28f7db15 100644 --- a/apps/desktop/lib/src/shell/toasts.dart +++ b/apps/desktop/lib/src/shell/toasts.dart @@ -58,7 +58,11 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) { /// all (chat pop-ups, sitting out, alone) is the Notifier's policy. class PartyToasts extends StatefulWidget { final VoidCallback? onOpenChat; - const PartyToasts({super.key, this.onOpenChat}); + + /// Toasts shown another way (flying as danmaku, your quick chat echo): + /// left out when they come. + final bool Function(ToastItem t)? skip; + const PartyToasts({super.key, this.onOpenChat, this.skip}); @override State createState() => _PartyToastsState(); @@ -86,6 +90,7 @@ class _PartyToastsState extends State { // Only new toasts re-run this, not the party they mention. untracked(() { for (final t in fresh) { + if (widget.skip?.call(t) ?? false) continue; _add(shellToastFor(t, (n) => avatarForName(n, coord, t.actorId))); } if (gone.isNotEmpty) _leave(gone); diff --git a/apps/desktop/lib/src/shell/video_view.dart b/apps/desktop/lib/src/shell/video_view.dart index de8e6ed6..67d042f0 100644 --- a/apps/desktop/lib/src/shell/video_view.dart +++ b/apps/desktop/lib/src/shell/video_view.dart @@ -1,15 +1,16 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:flutter/foundation.dart' show ValueListenable; 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. +// size its output renders at ("Sharp video and subtitles") and how far +// bottom subtitles move up while something covers the bottom of the +// picture (the quick chat box; the control bar when chosen). /// 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 @@ -115,42 +116,94 @@ double subtitleLiftPos({required Size view, int? videoW, int? videoH, required d 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. +/// The subtitle lift moves in [liftStepCount] steps [liftStepGap] apart +/// (about 130 ms): each `sub-pos` change makes mpv re-render the +/// subtitles, and a playing video shows a change only with its next +/// frame, so a change per screen frame (60 a second against a video's 24) +/// landed unevenly and looked choppy. One step per frame of a 24 fps +/// video, eased out (most of the way in the first step), reads as one +/// quick, smooth move. +const liftStepCount = 4; +const liftStepGap = Duration(milliseconds: 42); + +/// The covered heights a lift from [from] to [to] passes through, one per +/// step, eased out; the last is exactly [to]. Empty when there is nothing +/// to move. +List liftSteps(double from, double to, {int count = liftStepCount}) { + if ((to - from).abs() < 0.5) return const []; + return [ + for (var i = 1; i <= count; i++) i == count ? to : from + (to - from) * Curves.easeOutQuad.transform(i / count), + ]; +} + +/// How much of the bottom of the view is covered, as it moves: [value] +/// the covered height in logical px (0: nothing), stepping toward the +/// latest [animateTo] target ([liftSteps], [liftStepGap] apart; a new +/// target starts from where it is). mpv's `sub-pos` follows it +/// ([SubtitleLifter]), and so do the danmaku preview's mock subtitles. +class SubtitleLift extends ChangeNotifier implements ValueListenable { + double _value = 0; + double _target = 0; + List _steps = const []; + int _next = 0; + Timer? _timer; + + @override + double get value => _value; + double get target => _target; + + /// Moves toward [target] (px); the first step at once. + void animateTo(double target) { + target = math.max(0, target); + if ((target - _target).abs() < 0.5) return; + _target = target; + _timer?.cancel(); + _timer = null; + _steps = liftSteps(_value, target); + _next = 0; + if (_steps.isEmpty) return; + _step(); + if (_next < _steps.length) _timer = Timer.periodic(liftStepGap, (_) => _step()); + } + + void _step() { + if (_next >= _steps.length) return; + _value = _steps[_next++]; + if (_next >= _steps.length) { + _timer?.cancel(); + _timer = null; + } + notifyListeners(); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} + +/// Bottom subtitles of the current player sit above what covers the +/// bottom of the full player ([lift]: the quick chat box, and the control +/// bar while it is up when [AppSettings.liftSubtitles] is on): mpv +/// `sub-pos` follows each step of [lift], only as far as the letterbox +/// under the picture leaves no room ([subtitleLiftPos]). 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; + final SubtitleLift 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, - }); + const SubtitleLifter({super.key, required this.playback, 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; +class _SubtitleLifterState extends State { + static const _home = '100.0'; + String _sent = _home; HostedPlayer? _player; Size _view = Size.zero; late final void Function() _stop; @@ -158,77 +211,61 @@ class _SubtitleLifterState extends State with SingleTickerProvid @override void initState() { super.initState(); - widget.changes.addListener(_retarget); + widget.lift.addListener(_apply); + // A new player starts where the file wants its subtitles. _stop = effect(() { - widget.lift.value; widget.playback.generation.value; - untracked(_retarget); + untracked(_apply); }); } @override void didUpdateWidget(SubtitleLifter old) { super.didUpdateWidget(old); - if (old.changes != widget.changes) { - old.changes.removeListener(_retarget); - widget.changes.addListener(_retarget); + if (old.lift != widget.lift) { + old.lift.removeListener(_apply); + widget.lift.addListener(_apply); } - _retarget(); + if (old.active != widget.active) _apply(); } @override void dispose() { - widget.changes.removeListener(_retarget); + widget.lift.removeListener(_apply); _stop(); - _anim.dispose(); // Leave the player as the file wants it. - if (_sent != 100) _send(100); + if (_sent != _home) _send(_home); 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() { + void _apply() { 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; + _sent = _home; } - final t = _target; - if ((t - _to).abs() < 0.1) return; - _from = _sent; - _to = t; - _anim.forward(from: 0); + final cover = widget.active ? widget.lift.value : 0.0; + final st = p?.state; + final pos = cover <= 0 + ? 100.0 + : subtitleLiftPos(view: _view, videoW: st?.width, videoH: st?.height, controls: cover, gap: 0); + final v = pos.toStringAsFixed(1); + if (v != _sent) _send(v); } - 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) { + void _send(String pos) { _sent = pos; final p = widget.playback.current; if (p == null || p.disposed) return; - unawaited(p.setProperty('sub-pos', pos.toStringAsFixed(1)).catchError((Object _) {})); + unawaited(p.setProperty('sub-pos', pos).catchError((Object _) {})); } @override Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) { if (box.biggest != _view) { _view = box.biggest; - WidgetsBinding.instance.addPostFrameCallback((_) => _retarget()); + WidgetsBinding.instance.addPostFrameCallback((_) => _apply()); } return const SizedBox.shrink(); }); diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index 8e1963a9..f019f25b 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -5,6 +5,7 @@ import 'package:hive_ce/hive.dart'; import 'package:signals/signals_flutter.dart'; import '../controls/model/controls_prefs.dart'; +import '../danmaku/danmaku.dart' show DanmakuStyle; import '../shaders/shader_presets.dart'; import '../session/notice_prefs.dart'; import 'settings_schema.dart'; @@ -48,9 +49,21 @@ const _keyWhatsNewSeen = 'whatsNewSeen'; const _keyWhatsNewNotes = 'whatsNewNotes'; const _keyScreenResVideo = 'screenResVideo'; const _keyLiftSubtitles = 'liftSubtitles'; +const _keyNotifyStyle = 'notifyStyle'; +const _keySubScale = 'subScale'; +const _keyDanmakuStyle = 'danmakuStyle'; enum HwdecMode { auto, off, custom } +/// How notifications show over the full player ([AppSettings.notifyStyle]). +enum NotifyStyle { + toasts('Toasts'), + danmaku('Danmaku'); + + final String label; + const NotifyStyle(this.label); +} + /// The mpv `hwdec` of [HwdecMode.auto]. Windows: 'auto-copy' (decoded on /// the GPU, frames copied back): media_kit renders through ANGLE, where /// plain 'auto' picks the dxva2 interop, which cannot create its EGL @@ -96,9 +109,19 @@ class AppSettings { /// 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. + /// Bottom subtitles also move up above the control bar while it is up + /// (mpv `sub-pos`; the quick chat box always lifts them). Default off. + /// Applies live. final liftSubtitles = signal(false); + /// Subtitle size, mpv `sub-scale` (1: as the file has them), 0.5..2. + /// Applies live and to each new player. + final subScale = signal(1.0); + /// How notifications show over the full player: toasts (the default), + /// or chat and short notices flying across the picture as danmaku + /// (`danmaku/danmaku_notices.dart`; problems stay toasts). Applies live. + final notifyStyle = signal(NotifyStyle.toasts); + /// How those lines look and move (stored as one map, [DanmakuStyle.toJson]). + final danmakuStyle = signal(const DanmakuStyle()); /// 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 @@ -189,6 +212,12 @@ class AppSettings { if (screenRes is bool) screenResVideo.value = screenRes; final lift = box.get(_keyLiftSubtitles); if (lift is bool) liftSubtitles.value = lift; + final scale = box.get(_keySubScale); + if (scale is num && scale.isFinite) subScale.value = scale.toDouble().clamp(0.5, 2.0); + final notifyAs = box.get(_keyNotifyStyle); + notifyStyle.value = NotifyStyle.values.where((m) => m.name == notifyAs).firstOrNull ?? NotifyStyle.toasts; + final style = box.get(_keyDanmakuStyle); + if (style is Map) danmakuStyle.value = DanmakuStyle.fromJson(style); final share = box.get(_keyShareDiagnostics); if (share is bool) shareDiagnostics.value = share; final popups = box.get(_keyChatPopups); @@ -303,6 +332,22 @@ class AppSettings { _put(_keyLiftSubtitles, on); } + void setSubScale(double v) { + subScale.value = v.clamp(0.5, 2.0); + _put(_keySubScale, subScale.value); + } + + void setNotifyStyle(NotifyStyle style) { + notifyStyle.value = style; + _put(_keyNotifyStyle, style.name); + } + + void setDanmakuStyle(DanmakuStyle style) { + if (style == danmakuStyle.peek()) return; + danmakuStyle.value = style; + _put(_keyDanmakuStyle, style.toJson()); + } + 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 index 3e344a7f..7851a525 100644 --- a/apps/desktop/test/danmaku_test.dart +++ b/apps/desktop/test/danmaku_test.dart @@ -3,6 +3,8 @@ 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/danmaku/danmaku_notices.dart'; +import 'package:starsync/src/session/notifier.dart' show NoticeCategory, ToastItem; import 'package:starsync/src/shell/avatar.dart' show AvatarInfo; // Danmaku lanes: free lanes only, no two lines in a lane ever touch, the @@ -173,4 +175,87 @@ void main() { expect(c.waiting.value, 0); await tester.pumpWidget(const SizedBox()); }); + + + group('DanmakuStyle as a setting', () { + test('round-trips through its map', () { + const s = DanmakuStyle( + crossSecs: 6, maxOnScreen: 5, bandTop: 0.2, bandBottom: 0.5, opacity: 0.7, size: 1.3, weight: 800, + outline: 2, highlightOwn: false, avatars: false); + expect(DanmakuStyle.fromJson(s.toJson()), s); + }); + + test('junk and out-of-range values: defaults and the nearest ends', () { + expect(DanmakuStyle.fromJson(const {}), const DanmakuStyle()); + final s = DanmakuStyle.fromJson(const { + 'crossSecs': 99, + 'maxOnScreen': 0, + 'opacity': 'x', + 'weight': 1234, + 'bandTop': 0.6, + 'bandBottom': 0.61, + 'avatars': 1, + }); + expect(s.crossSecs, 16); + expect(s.maxOnScreen, 1); + expect(s.opacity, const DanmakuStyle().opacity); + expect(s.weight, 900); + expect((s.bandTop, s.bandBottom), (0.10, 0.45), reason: 'a band too thin takes the default'); + expect(s.avatars, isTrue); + }); + + test('boldness: the text at its weight, the name a step bolder', () { + expect(const DanmakuStyle(weight: 400).fontWeight, FontWeight.w400); + expect(const DanmakuStyle(weight: 400).nameWeight, FontWeight.w500); + expect(const DanmakuStyle(weight: 900).nameWeight, FontWeight.w900); + }); + }); + + group('notifications as danmaku', () { + ToastItem toast(String text, + {String? actor, NoticeCategory category = NoticeCategory.app, bool chat = false, bool own = false}) => + ToastItem(id: 1, text: text, at: DateTime(2026), actor: actor, chat: chat, own: own, category: category); + + test('chat and short notices fly; problems, nudges and the app stay toasts', () { + expect(fliesAsDanmaku(toast('Bob: hi', actor: 'Bob', chat: true, category: NoticeCategory.chat)), isTrue); + expect(fliesAsDanmaku(toast('Bob joined', actor: 'Bob', category: NoticeCategory.people)), isTrue); + expect(fliesAsDanmaku(toast('Starting now', category: NoticeCategory.start)), isTrue); + expect(fliesAsDanmaku(toast('Bob added X to Up next', actor: 'Bob', category: NoticeCategory.upNext)), isTrue); + expect(fliesAsDanmaku(toast('Skipped X: gone', category: NoticeCategory.upNext)), isFalse); + expect(fliesAsDanmaku(toast("Bob can't play this", actor: 'Bob', category: NoticeCategory.problem)), isFalse); + expect(fliesAsDanmaku(toast('Bob nudged you', actor: 'Bob', category: NoticeCategory.nudge)), isFalse); + expect(fliesAsDanmaku(toast('Link copied')), isFalse); + expect(fliesAsDanmaku(toast('The server restarted', category: NoticeCategory.self)), isFalse); + }); + + test('lines: the sender before the message, own chat framed', () { + final chat = danmakuForToast(toast('Me: hello', actor: 'Me', chat: true, own: true, category: NoticeCategory.chat))!; + expect((chat.kind, chat.sender, chat.text, chat.own), (DanmakuKind.chat, 'Me', 'hello', true)); + final left = danmakuForToast(toast('Bob lost connection', actor: 'Bob', category: NoticeCategory.people))!; + expect((left.kind, left.text), (DanmakuKind.leave, 'lost connection')); + final start = danmakuForToast(toast('Starting now: Frieren', category: NoticeCategory.start))!; + expect((start.kind, start.sender), (DanmakuKind.starting, null)); + expect(danmakuForToast(toast('Copied')), isNull); + }); + }); + + testWidgets('an idle layer asks for no frames', (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)), + )); + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.binding.hasScheduledFrame, isFalse); + c.add(const DanmakuItem(kind: DanmakuKind.chat, sender: 'Bob', text: 'hi')); + await tester.pump(const Duration(milliseconds: 16)); + expect(c.onScreen.value, 1); + expect(tester.binding.hasScheduledFrame, isTrue); + await tester.pump(const Duration(seconds: 9)); + await tester.pump(const Duration(milliseconds: 16)); + expect(c.onScreen.value, 0); + expect(tester.binding.hasScheduledFrame, isFalse); + await tester.pumpWidget(const SizedBox()); + }); } diff --git a/apps/desktop/test/player_settings_test.dart b/apps/desktop/test/player_settings_test.dart index 6df95e1f..d45c1658 100644 --- a/apps/desktop/test/player_settings_test.dart +++ b/apps/desktop/test/player_settings_test.dart @@ -1,12 +1,12 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:signals/signals_flutter.dart' show signal; import 'package:sync_relay_core/core.dart'; -import 'package:starsync/src/controls/frame.dart'; import 'package:starsync/src/controls/model/controls_prefs.dart'; -import 'package:starsync/src/controls/player_settings.dart'; +import 'package:starsync/src/controls/player_panel.dart'; +import 'package:starsync/src/danmaku/danmaku.dart' show DanmakuStyle; import 'package:starsync/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/schedule/animeflow.dart'; import 'package:starsync/src/shaders/shader_presets.dart'; @@ -37,6 +37,8 @@ void bigWindow(WidgetTester tester, [Size size = const Size(1280, 1100)]) { } Future pick(WidgetTester tester, String key, String item) async { + await tester.ensureVisible(find.byKey(ValueKey(key))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(ValueKey(key))); await tester.pumpAndSettle(); await tester.tap(find.text(item).last); @@ -57,6 +59,8 @@ void main() { s.shaderPreset.value = ShaderPreset.off; s.shaderQuality.value = ShaderQuality.hq; s.hwdecMode.value = HwdecMode.auto; + s.liftSubtitles.value = false; + s.notifyStyle.value = NotifyStyle.toasts; coord.playback.tracks.value = const []; coord.serverState.value = null; coord.hostFile = null; @@ -64,76 +68,66 @@ void main() { _graph.forms.recents.value = const []; }); - Widget opener() => Builder( - builder: (context) => TextButton( - key: const ValueKey('open-settings'), - onPressed: () => showPlayerSettings(context), - child: const Text('settings'), - ), - ); - - group('player settings card', () { - testWidgets('lists every player setting, changes apply live', (tester) async { + group('player panel', () { + testWidgets('tracks, subtitles, picture, sound, notifications and controls; all live', (tester) async { bigWindow(tester); coord.playback.tracks.value = const [ MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', codec: 'eac3', channels: 2, selected: true), MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', codec: 'ass', title: 'Full'), ]; - await tester.pumpWidget(scoped(_graph, app(Align(alignment: Alignment.bottomRight, child: opener())))); - await tester.tap(find.byKey(const ValueKey('open-settings'))); + var volume = 80.0; + var closed = 0; + final vol = signal(volume); + await tester.pumpWidget(scoped( + _graph, + app(Align( + alignment: Alignment.topRight, + child: SizedBox( + height: 1000, + child: PlayerPanel( + onClose: () => closed++, + volume: vol, + onVolume: (v) => vol.value = volume = v, + ), + ), + )))); await tester.pumpAndSettle(); - final card = find.byKey(const ValueKey('player-settings-card')); - expect(card, findsOneWidget); - for (final label in [ - 'Player design', - 'Progress line when hidden', - 'Upscaling shader', - 'Hardware decoding', - 'Subtitles', - 'Audio', - ]) { - expect(find.descendant(of: card, matching: find.text(label)), findsOneWidget, reason: label); + final panel = find.byKey(const ValueKey('player-panel')); + expect(panel, findsOneWidget); + for (final label in ['Subtitles', 'Audio', 'Size', 'Upscaling', 'Hardware decoding', 'Volume']) { + expect(find.descendant(of: panel, matching: find.text(label)), findsWidgets, reason: label); } // Subtitles off (none selected), audio on the selected track. - expect(find.descendant(of: find.byKey(const ValueKey('subtitle-track')), matching: find.text('Off')), + expect(find.descendant(of: find.byKey(const ValueKey('panel-subtitles')), matching: find.text('Off')), findsOneWidget); - expect(find.descendant(of: find.byKey(const ValueKey('audio-track')), matching: find.textContaining('Japanese')), + expect(find.descendant(of: find.byKey(const ValueKey('panel-audio')), matching: find.textContaining('Japanese')), findsOneWidget); - - await pick(tester, 'player-design', 'Minimal'); - expect(s.controlsSkin.value, ControlsSkin.minimal); - await tester.tap(find.byKey(const ValueKey('progress-line'))); - await tester.pump(); - expect(s.progressLine.value, isFalse); - await pick(tester, 'shader-preset', 'Mode B'); + await pick(tester, 'panel-shader', 'Mode B'); expect(s.shaderPreset.value, ShaderPreset.b); - await pick(tester, 'shader-quality', 'Fast'); - expect(s.shaderQuality.value, ShaderQuality.fast); - await pick(tester, 'hwdec', 'Off'); + await pick(tester, 'panel-hwdec', 'Off'); expect(s.hwdec.value, 'no'); - // Esc closes it. - await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.tap(find.descendant(of: find.byKey(const ValueKey('panel-lift')), matching: find.byType(Switch))); + await tester.pump(); + expect(s.liftSubtitles.value, isTrue); + await tester.drag(find.descendant(of: find.byKey(const ValueKey('panel-volume')), matching: find.byType(Slider)), + const Offset(-60, 0)); + expect(volume, lessThan(80)); + // Folded sections open with a click: notifications, then danmaku's knobs. + expect(find.byKey(const ValueKey('notify-style')), findsNothing); + await tester.tap(find.byKey(const ValueKey('panel-section-Notifications'))); await tester.pumpAndSettle(); - expect(card, findsNothing); - }); - - testWidgets('keeps the controls up while open', (tester) async { - bigWindow(tester); - await tester.pumpWidget(scoped(_graph, app(ControlsFrame( - hideAfter: const Duration(seconds: 1), - builder: (context, visible) => Align(alignment: Alignment.bottomRight, child: opener()), - )))); - final controls = tester.state(find.byType(ControlsFrame)); - await tester.tap(find.byKey(const ValueKey('open-settings'))); + await pick(tester, 'notify-style', 'Danmaku'); + expect(s.notifyStyle.value, NotifyStyle.danmaku); + expect(find.byKey(const ValueKey('danmaku-weight')), findsOneWidget); + await tester.ensureVisible(find.byKey(const ValueKey('panel-section-Controls'))); + await tester.tap(find.byKey(const ValueKey('panel-section-Controls'))); await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('player-settings-card')), findsOneWidget); - expect(find.text('Standard'), findsOneWidget, reason: 'the current skin'); - await tester.pump(const Duration(seconds: 3)); - expect(controls.visible, isTrue, reason: 'held while the card is open'); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await pick(tester, 'panel-design', 'Minimal'); + expect(s.controlsSkin.value, ControlsSkin.minimal); + await tester.ensureVisible(find.byKey(const ValueKey('player-panel-close'))); await tester.pumpAndSettle(); - await tester.pump(const Duration(seconds: 2)); - expect(controls.visible, isFalse); + await tester.tap(find.byKey(const ValueKey('player-panel-close'))); + expect(closed, 1); }); }); @@ -169,6 +163,37 @@ void main() { expect(find.byIcon(Icons.hub_outlined), findsOneWidget); }); + testWidgets('Notifications: Toasts / Danmaku, a preview strip and every knob of the look', (tester) async { + addTearDown(() => s.danmakuStyle.value = const DanmakuStyle()); + await pumpSettings(tester, SettingsSection.notifications, size: const Size(1280, 2600)); + final card = find.byKey(const ValueKey('danmaku-settings')); + expect(card, findsOneWidget); + expect(find.byKey(const ValueKey('danmaku-preview-strip')), findsOneWidget); + expect(s.notifyStyle.value, NotifyStyle.toasts, reason: 'toasts by default'); + await pick(tester, 'notify-style', 'Danmaku'); + expect(s.notifyStyle.value, NotifyStyle.danmaku); + for (final k in ['cross', 'cap', 'band', 'opacity', 'size', 'weight', 'outline', 'avatars', 'own']) { + expect(find.byKey(ValueKey('danmaku-$k')), findsOneWidget, reason: k); + } + // Boldness: one step right of Semibold is Bold. + final weight = find.byKey(const ValueKey('danmaku-weight')); + final box = tester.getRect(weight); + await tester.tapAt(Offset(box.left + box.width * 0.6, box.center.dy)); + await tester.pump(); + expect(s.danmakuStyle.value.weight, 700); + expect(find.text('Bold'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('danmaku-avatars'))); + await tester.pump(); + expect(s.danmakuStyle.value.avatars, isFalse); + await tester.tap(find.byKey(const ValueKey('danmaku-reset'))); + await tester.pump(); + expect(s.danmakuStyle.value, const DanmakuStyle()); + // The strip rests after a while and plays again on a click. + await tester.pump(const Duration(seconds: 20)); + expect(find.byKey(const ValueKey('danmaku-preview-play')), findsOneWidget); + await tester.pumpAndSettle(); + }); + testWidgets('Player: the card\'s rows, a pointer to it, and the controls\' choices', (tester) async { await pumpSettings(tester, SettingsSection.player, size: const Size(1280, 2400)); expect(find.textContaining('also in the player: ⚙ → Player settings'), findsOneWidget); @@ -177,6 +202,8 @@ void main() { 'progress-line', 'shader-preset', 'hwdec', + 'sub-scale', + 'lift-subtitles', 'time-display', 'controls-size', 'controls-hide', diff --git a/apps/desktop/test/quick_chat_test.dart b/apps/desktop/test/quick_chat_test.dart new file mode 100644 index 00000000..971405be --- /dev/null +++ b/apps/desktop/test/quick_chat_test.dart @@ -0,0 +1,226 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart' show VideoState; +import 'package:starsync/src/controls/frame.dart'; +import 'package:starsync/src/danmaku/danmaku.dart'; +import 'package:starsync/src/session/navigation.dart' show Place; +import 'package:starsync/src/session/notifier.dart' show NoticeCategory, ToastItem; +import 'package:starsync/src/shell/quick_chat.dart'; +import 'package:starsync/src/shell/session_player.dart'; +import 'package:starsync/src/state/settings.dart'; +import 'package:starsync/src/widgets/app_keys.dart'; + +import 'test_graph.dart'; + +/// This file's composition root (see test_graph.dart). +final _graph = testGraph(); + +// The quick chat box over the full player: Enter opens it whatever plays, +// keys go to it (not to the player), the cursor and the controls stay as +// the mouse left them, a send shows your own line (echo, or danmaku), Esc +// and the idle time close it with the draft kept, the draft is the chat +// panel's. The picture is a black box with the session's controls on it +// (never a media_kit `Video`). + +void main() { + final coord = _graph.coordinator; + final s = coord.settings; + + setUpAll(() async { + MediaKit.ensureInitialized(); + await coord.media.start(); + _graph.navigation.place.value = Place.yours; + }); + + late TextEditingController draft; + late QuickChat quick; + var fullscreenToggles = 0; + var readyToggles = 0; + + setUp(() { + draft = TextEditingController(); + quick = QuickChat(draft: draft); + fullscreenToggles = 0; + readyToggles = 0; + }); + + tearDown(() { + s.notifyStyle.value = NotifyStyle.toasts; + }); + + Future mount(WidgetTester tester) async { + tester.view.physicalSize = const Size(1280, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(scoped( + _graph, + MaterialApp( + theme: ThemeData.dark(), + home: AppKeys( + onToggleReady: () => readyToggles++, + child: Scaffold( + body: SessionPlayer( + mode: PlayerMode.full, + fullscreen: false, + onExpand: () {}, + onCollapse: () {}, + onClose: () {}, + onToggleFullscreen: () => fullscreenToggles++, + onToggleChat: () {}, + onChangeVideo: () {}, + onOpenIntegrations: () {}, + onLeave: () {}, + windowControls: false, + quickChat: quick, + videoBuilder: (context, controls) => Stack(fit: StackFit.expand, children: [ + const ColoredBox(color: Colors.black), + if (controls != null) controls(VideoState()), + ]), + ), + ), + ), + ), + )); + await tester.pump(); + } + + Future unmount(WidgetTester tester) async { + await tester.pumpWidget(scoped(_graph, const SizedBox())); + await tester.pump(const Duration(seconds: 5)); + quick.dispose(); + draft.dispose(); + } + + ControlsFrameState frame(WidgetTester tester) => tester.state(find.byType(ControlsFrame)); + MouseCursor frameCursor(WidgetTester tester) => tester + .widget(find.descendant(of: find.byType(ControlsFrame), matching: find.byType(MouseRegion)).first) + .cursor; + final field = find.byKey(const ValueKey('quick-chat-field')); + bool fieldFocused() => quick.focus.hasFocus; + + Future hideControls(WidgetTester tester) async { + await tester.pump(Duration(seconds: s.controlsHideSecs.value + 1)); + await tester.pump(const Duration(milliseconds: 500)); + } + + testWidgets('Enter opens the box; keys go to it; the cursor and controls stay hidden', (tester) async { + await mount(tester); + // A still mouse over the bottom centre, where the box comes up. + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: const Offset(640, 690)); + addTearDown(mouse.removePointer); + await tester.pump(); + await hideControls(tester); + expect(frame(tester).visible, isFalse); + expect(frameCursor(tester), SystemMouseCursors.none); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(quick.open.value, isTrue); + expect(fieldFocused(), isTrue, reason: 'the box has the keyboard at once'); + await tester.pump(const Duration(milliseconds: 400)); + expect(frame(tester).visible, isFalse, reason: 'typing is not mouse activity'); + expect(frameCursor(tester), SystemMouseCursors.none); + final fieldWidget = tester.widget(field); + expect(fieldWidget.mouseCursor, SystemMouseCursors.none, reason: 'no I-beam over a still mouse'); + + // Space and F reach the box, not ready or fullscreen. + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.pump(); + expect(readyToggles, 0); + expect(fullscreenToggles, 0); + + // Esc closes; the draft stays; the controls do not come back. + await tester.enterText(field, 'half a thought'); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(const Duration(milliseconds: 400)); + expect(quick.open.value, isFalse); + expect(draft.text, 'half a thought'); + expect(frame(tester).visible, isFalse, reason: 'the box going is not mouse movement'); + expect(frameCursor(tester), SystemMouseCursors.none); + + // The player's keys are back. + await tester.sendKeyEvent(LogicalKeyboardKey.space); + expect(readyToggles, 1); + await tester.pump(const Duration(milliseconds: 400)); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + expect(fullscreenToggles, 1); + + // Enter brings the draft back. + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(quick.open.value, isTrue); + expect(tester.widget(field).controller!.text, 'half a thought'); + await unmount(tester); + }); + + testWidgets('a send shows your own line: the echo with toasts, and the controls stay hidden', (tester) async { + await mount(tester); + await hideControls(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.enterText(field, 'hello there'); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(quick.open.value, isFalse); + expect(draft.text, isEmpty, reason: 'sent: the shared draft is cleared'); + await tester.pump(const Duration(milliseconds: 400)); + final echo = find.byKey(const ValueKey('quick-chat-echo')); + expect(echo, findsOneWidget); + expect(find.descendant(of: echo, matching: find.text('hello there')), findsOneWidget); + expect(frame(tester).visible, isFalse, reason: 'held down while the echo is in their place'); + // Your own chat toast coming back is not shown again. + final own = ToastItem( + id: 99, text: 'Me: hello there', at: DateTime.now(), actor: 'Me', chat: true, own: true, + category: NoticeCategory.chat); + expect(quick.echoed(own), isTrue); + await tester.pump(QuickChat.echoTime); + await tester.pump(const Duration(milliseconds: 400)); + expect(echo, findsNothing); + expect(frame(tester).visible, isFalse); + // Empty Enter just closes. + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(const Duration(milliseconds: 400)); + expect(quick.open.value, isFalse); + expect(echo, findsNothing); + await unmount(tester); + }); + + testWidgets('with danmaku your own line flies instead of the echo', (tester) async { + s.notifyStyle.value = NotifyStyle.danmaku; + await mount(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.enterText(field, 'flying'); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + expect(find.byKey(const ValueKey('quick-chat-echo')), findsNothing); + final line = find.descendant(of: find.byType(DanmakuLayer), matching: find.textContaining('flying', findRichText: true)); + expect(line, findsWidgets); + await tester.pump(const Duration(seconds: 10)); + await unmount(tester); + }); + + testWidgets('the box hides by itself after a while without typing; the draft stays', (tester) async { + await mount(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.enterText(field, 'later'); + await tester.pump(QuickChat.idleClose - const Duration(seconds: 1)); + expect(quick.open.value, isTrue); + await tester.enterText(field, 'later, maybe'); + await tester.pump(QuickChat.idleClose - const Duration(seconds: 1)); + expect(quick.open.value, isTrue, reason: 'typing restarts the wait'); + await tester.pump(const Duration(seconds: 2)); + expect(quick.open.value, isFalse); + expect(draft.text, 'later, maybe'); + await unmount(tester); + }); +} diff --git a/apps/desktop/test/singletons_test.dart b/apps/desktop/test/singletons_test.dart index c5bfea4c..4dae89fc 100644 --- a/apps/desktop/test/singletons_test.dart +++ b/apps/desktop/test/singletons_test.dart @@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; /// (`AppGraph`, widgets through `AppScope`); this list may only shrink. /// Lower a number when a use goes; never raise one or add a class. const baseline = { - 'AppSettings': 49, + 'AppSettings': 47, 'JellyfinAccounts': 10, 'UpdateController': 8, 'Profile': 8, diff --git a/apps/desktop/test/video_view_test.dart b/apps/desktop/test/video_view_test.dart index 4534fd22..732b4ca3 100644 --- a/apps/desktop/test/video_view_test.dart +++ b/apps/desktop/test/video_view_test.dart @@ -45,6 +45,47 @@ void main() { }); }); + group('subtitle lift steps', () { + test('a few large eased-out steps, the last exactly on target', () { + final up = liftSteps(0, 100); + expect(up, hasLength(liftStepCount)); + expect(up.last, 100); + // Eased out: the first step moves most, each one less. + for (var i = 1; i < up.length; i++) { + expect(up[i], greaterThan(up[i - 1])); + if (i > 1) expect(up[i] - up[i - 1], lessThan(up[i - 1] - (i > 1 ? up[i - 2] : 0))); + } + expect(up.first, greaterThan(30), reason: 'most of the way at once'); + final down = liftSteps(100, 0); + expect(down.last, 0); + expect(down.first, lessThan(70)); + expect(liftSteps(40, 40.2), isEmpty); + }); + + testWidgets('SubtitleLift steps toward the latest target, liftStepGap apart', (tester) async { + final lift = SubtitleLift(); + addTearDown(lift.dispose); + final seen = []; + lift.addListener(() => seen.add(lift.value)); + lift.animateTo(80); + expect(seen, hasLength(1), reason: 'the first step at once'); + await tester.pump(liftStepGap * (liftStepCount - 1)); + expect(seen, liftSteps(0, 80)); + expect(lift.value, 80); + await tester.pump(liftStepGap * 4); + expect(seen, hasLength(liftStepCount), reason: 'nothing after the target'); + // A new target mid-way starts from where it is. + seen.clear(); + lift.animateTo(0); + await tester.pump(liftStepGap); + final from = lift.value, n = seen.length; + expect(from, inExclusiveRange(0, 80)); + lift.animateTo(80); + await tester.pump(liftStepGap * liftStepCount); + expect(seen.sublist(n), liftSteps(from, 80)); + }); + }); + 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); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 06b869a5..9ae75991 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -600,7 +600,7 @@ 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): +on by default, live; Settings → Player and the player panel): 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 @@ -2121,7 +2121,8 @@ panel) and chat messages (Copy message, and a link in it). Jellyfin & integrations (linked servers, AnimeFlow's `/me` via `AnimeFlowClient.fromAccounts`, "Play the next episode automatically": whether this app adds the continuation, see "What's next"), notifications & sounds - (what pops up, chat pop-ups, joins and leaves while sitting out or in + (what pops up, chat pop-ups, toasts or danmaku over the player with the + danmaku look, joins and leaves while sitting out or in the background), updates, shortcuts, about. - **Drop and paste** (`handoff.dart`, `fox_utils`): the shell is one `DropTarget` (a "Drop to open" overlay while dragging) and a @@ -2218,14 +2219,28 @@ panel) and chat messages (Copy message, and a link in it). and the members card says "can't play this: ...". 34 px avatars, a main line and a muted detail line. The pill glass, colour only for problems; it fades through 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, 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 - "Player controls"). The progress line (`AppSettings.progressLine`) is + Player settings (both skins) toggles the player panel + (`controls/player_panel.dart`, `PlayerControlsModel.panelOpen`): a + 312 px `AppGlass` column docked at the right of the full player, + between the top bar and the control bar (it never covers the bar), + sliding and fading in from the right; it stays open while the controls + hide and show, until its × or Esc (the player's Esc closes it first), + and leaving the full player closes it. The mouse on it holds the + controls up (`ControlsHoldRegion`). Its sections fold (their titles + toggle; folded ones are kept for the app's run, Notifications and + Controls start folded): tracks (subtitle, audio; remembered as from + the track menus), subtitles (size, mpv `sub-scale` from + `AppSettings.subScale`, set on every player; above the controls, see + "Subtitle lift"), picture (upscaling shader and quality, sharp video + and subtitles, see "Media", hardware decoding, set live on mpv's + `hwdec`), sound (volume), notifications (Toasts / Danmaku and, with + Danmaku, the look's knobs, see "Danmaku notifications") and controls + (design, progress line); a pointer to Settings → Player and Mark a + problem at its foot. Compact rows of its own over the same settings and + actions; Settings → Player shows the full rows (`playerSettingRows`: + design, progress line, shader, sharp video, hardware decoding, subtitle + size, subtitles above the controls) and the controls' own choices + (`controlsSettingRows`, see "Player controls"); all apply live. The progress line (`AppSettings.progressLine`) is what the bottom controls leave while hidden: Standard a 2 px line, Minimal its collapsed timeline. Fullscreen adds the `WindowButtons` to the top bar while the controls are up (restore @@ -2261,7 +2276,8 @@ panel) and chat messages (Copy message, and a link in it). - **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right (strip button or Tab): chat with avatars; the "Activity" chip (off by default, `showActivity`, kept for the app's run only) adds the other - feed lines between them. Opening focuses the field; Tab or Esc in it + feed lines between them. Its field shares the quick chat box's draft + (see "Quick chat"). Opening focuses the field; Tab or Esc in it close the panel and focus goes back where it was. Unread chat badges the strip's and the player controls' chat buttons; in fullscreen with the controls down and the video not playing a glass chip @@ -2279,7 +2295,96 @@ panel) and chat messages (Copy message, and a link in it). are up and come down to 16 px from the edge while they are hidden (`AnimatedPositioned`, the shell's `ease`; the player reports its controls through the shell's `controlsShown`); elsewhere 16 px from the - bottom left. What pops up at all is the Notifier's policy. + bottom left. What pops up at all is the Notifier's policy. Each + `ToastItem` carries its `NoticeCategory` and whether it is your own + (`own`); the shell leaves out those flying as danmaku and your own chat + coming back for a line the quick chat box already showed + (`PartyToasts.skip`). +- **Quick chat** (`shell/quick_chat.dart`): Enter in the full player + (not the mini: it is a corner thumbnail while you are elsewhere in the + app, where Enter belongs to that page; nor while sitting out) opens a + small box at the bottom centre, 16 px up, in the control bar's place, + without opening the chat panel. Enter is a `HardwareKeyboard` handler + of the player, so it works whatever plays and whatever button kept + focus; a `FocusManager` early handler swallows that Enter so the + focused button does not act on it too. `QuickChat` (the shell's, it + outlives the player's modes) holds the state: the draft is the chat + panel's `TextEditingController` (typing in either shows in both, a send + from either clears both), its `FocusNode` takes focus at once (keys + typed right after go to the box; `textInputFocused` keeps Space, Tab, + F and the arrows off the player meanwhile), and it closes by itself + after `idleClose` (8 s) without typing, Esc, or an empty Enter, keeping + the draft for the next Enter. Enter with text (an input method's + composing Enter excepted) sends it (`sendChat`) and closes. The box + (`QuickChatBox`) starts 300 px wide and widens with the text up to + 640 px (or the room there is), then wraps up to four lines, growing + upward; your avatar leads it; it fades and slides in on the shell's + curve and stays mounted while closed (invisible, ignoring the pointer) + so focus is immediate. What you sent shows where the box was for + `echoTime` (2.6 s, `QuickChatEcho`, with an accent hairline), or, with + danmaku notifications, flies at once as your own line; your own chat + toast coming back from the server is not shown again + (`QuickChat.echoed`). While the box or its echo is up the controls are + held hidden (`ControlsFrame.suppress`: drawn hidden, `shown` false, the + cursor still follows the mouse) and the action area rides above the + box. Typing is not mouse activity: the frame wakes the controls only + on real pointer movement (an enter or hover at the last seen position, + as when the box comes or goes under a still pointer, does nothing), and + the field's I-beam stays hidden until the mouse moves over it, so + Enter, typing and sending never show the cursor or the controls. +- **Subtitle lift** (`video_view.dart`): bottom subtitles move above what + covers the bottom of the full player: the quick chat box or its echo + (always), and the control bar while it is up when + `AppSettings.liftSubtitles` is on (off by default; Settings → Player + and the player panel, "Subtitles above the controls"); 8 px above it. + `SubtitleLift` steps the covered height toward each new target in + `liftStepCount` (4) eased-out steps `liftStepGap` (42 ms) apart, the + first at once: about one step per frame of a 24 fps video, so each + lands on its own video frame (a `sub-pos` change per screen frame + landed unevenly on the video's frames and looked choppy). + `SubtitleLifter` turns each step into mpv `sub-pos` (`subtitleLiftPos`: + only as far as the letterbox under the picture leaves no room, at most + half the picture), back to 100 when nothing covers and for a new + player. What each mpv property does, on `tool/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). +- **Danmaku notifications** (`danmaku/`): Settings → Notifications & + sounds → Over the player → Notifications: Toasts (default) or Danmaku + (`AppSettings.notifyStyle`, live; also in the player panel). With + Danmaku and the full player up (not sitting out), what + `fliesAsDanmaku` picks (chat, joins, leaves, lost connection and back, + "starting now", Up next news someone caused) flies right to left + across the picture (`DanmakuNotices` under the controls feeds new + toasts to a `DanmakuLayer` through `danmakuForToast`); nudges, + problems, your own state and the app's notices stay toasts, and so does + everything with the player small. The player sets the shell's + `danmakuActive` so the toasts leave the flying ones out. The layer: + 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 at the chosen weight (`weight`, 400 to 900, the name a step + bolder) with a dark outline and no box, the sender's avatar and + coloured name first, the app's own notices without one, own lines in + an accent frame; the layer's own clock (lines fly on while paused), + ticking only while lines fly or wait. Each line is a widget moved by a + `Flow`: per frame only paint runs. The look (`AppSettings.danmakuStyle`, + one map, `DanmakuStyle.toJson` / `fromJson`, which clamps every value + to `DanmakuStyle.limits`) has every knob in the settings card + (`danmaku_settings.dart`: crossing time, at most on screen, lane band, + opacity, size, boldness, outline, avatars, frame my own lines, reset) + over a preview strip (a moving stand-in picture, a top sign, bottom + dialogue and sample lines in the current look; it plays for 16 s after + it opens or a knob moves, then rests until clicked). Settings persist in two Hive boxes: `connect_form` (last form, recent servers and links, the profile picture and colour; saved even when a join @@ -2399,7 +2504,8 @@ Player controls (`lib/src/controls/`): one controls model, two skins. mark a problem, player settings, leave. Icons, tooltips (with keys) and the clock text (`clockText`) are defined there too, once. - **Shared pieces**: `ControlsFrame` (`frame.dart`): when the controls - are up (mouse movement shows them, `controlsHideSecs` without any or the + are up (real mouse movement shows them, see "Quick chat"; `suppress` + holds them hidden; `controlsHideSecs` without movement or the mouse leaving hides them and the cursor), holds (`hold` during a seek drag, `holdDuring` / `holdWhile` around menus and popovers, `holdBy` the session's `ControlsHold`), and the double-click on the picture @@ -2445,7 +2551,7 @@ part not downloaded still reads; chapter cuts are a light dark line. `mpvControls: true` from before becomes minimal), `timeDisplay`, `controlsSize` (normal / large: the bottom bar ×1.25), `controlsHideSecs` (2 / 3 / 5), `hiddenControls`, `progressLine`. - The player settings card has the skin and the progress line; Settings → + The player panel has the skin and the progress line; Settings → Player → Controls has the rest. - Pills (Skip OP / ED / 1:30, Next episode) and cards live outside the controls, in the action area ("Player overlay"). The cards stay up; a @@ -2597,7 +2703,7 @@ dedicated server (`package:logging` loggers named by area: `relay`, - **Opting out**: Settings → About → "Share diagnostics with the server" (default on). Off: no lines are kept or sent; one batch per connection says so (`share:false`). The local log is unchanged. -- **Mark a problem**: Ctrl+Shift+B (`AppKeys`), the player's ⚙ card and +- **Mark a problem**: Ctrl+Shift+B (`AppKeys`), the player panel and Settings → About open a small card with an optional note (200 characters, one line); the mark goes at once with the lines waiting, opted out or not, and the server writes `[who] === PROBLEM marked by diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 94f5c1e0..8760a0fe 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -85,52 +85,24 @@ video for subtitle placement and sharpness (default 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: +releases never show it: -- **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). +- **Danmaku preview** (`danmaku/danmaku_preview.dart`): the danmaku layer + of "Notifications: Danmaku" (ARCHITECTURE "Danmaku notifications") to + play with, starting from your own look; its knobs change only the + preview. 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 (mapped as the real feature maps them), every + `DanmakuStyle` knob, and mock subtitles (a top sign and bottom + dialogue). The mock dialogue moves with the subtitle lift's steps as + the real subtitles do: over the player when the quick chat box opens, + and on the stand-in page, which has its own quick chat box (Enter; + what you send flies as your own line). Tests: `danmaku_test`. ## Tests @@ -168,8 +140,11 @@ Three suites, run from each package's folder: 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`), 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 + subtitle lift steps and the dev-tools gate (`video_view_test`), danmaku + lanes, style, the toasts that fly and an idle layer + (`danmaku_test`), the quick chat box over the player (`quick_chat_test`), + the player panel and the danmaku settings card (`player_settings_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