- Quick chat (Enter in the full player, whatever plays): a box at the bottom centre in the control bar's place, compact and widening with the text, sharing its draft with the chat panel; Enter sends, Esc or an empty Enter closes, it closes by itself after 8 s without typing (the draft stays). Keys go to it; the Enter that opened it no longer reaches a focused button. Typing never shows the cursor or the controls: the frame wakes only on real mouse movement and holds the controls hidden while the box or its echo is up. Your sent line shows as an echo (or flies as your own danmaku line); your own chat toast is not repeated. - Subtitle lift: always for the box, for the control bar as a setting (off); 4 eased-out sub-pos steps 42 ms apart instead of one per frame. - Notifications: Toasts / Danmaku (off by default): chat and short notices fly over the full player, problems stay toasts; every knob of the look plus boldness, persisted, with a live preview strip in Settings. - Player panel: ⚙ → Player settings docks a foldable glass panel at the right (tracks, subtitle size and lift, picture, volume, notifications, controls) in place of the card; Esc closes it. - Danmaku preview: the mock dialogue follows the lift; the stand-in page has the chat box too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
398 lines
14 KiB
Dart
398 lines
14 KiB
Dart
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<String?>(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<double>? onHeight;
|
|
const QuickChatBox({super.key, required this.chat, required this.onSend, this.maxWidth = 640, this.onHeight});
|
|
|
|
@override
|
|
State<QuickChatBox> createState() => _QuickChatBoxState();
|
|
}
|
|
|
|
class _QuickChatBoxState extends State<QuickChatBox> {
|
|
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<String?>(
|
|
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<double>? 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<double>? 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));
|
|
}
|
|
}
|