Player: quick chat box, danmaku notifications, the player panel, smooth subtitle lift

- 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>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-18 17:29:13 +03:00
1 parent 6048fbe7d1
commit 69fae18fdc
29 files changed
+2755 -581

No files matched your search

+47 -10
View File
@@ -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<bool>? 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<ControlsFrame> {
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<ControlsFrame> {
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<ControlsFrame> {
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<ControlsFrame> {
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<ControlsFrame> {
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),
],
),
);