forked from Foxyz/sync_replay_project
- 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>
309 lines
10 KiB
Dart
309 lines
10 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:signals/signals_flutter.dart';
|
|
|
|
import '../session/session_coordinator.dart' show ToastItem;
|
|
import '../app/app_graph.dart';
|
|
import 'avatar.dart';
|
|
import 'motion.dart';
|
|
|
|
/// One toast on screen: who (null = the app itself), what, a picture
|
|
/// (an Up next item's), and whether it is on its way out.
|
|
class ShellToast {
|
|
final int id;
|
|
final AvatarInfo? who;
|
|
final IconData? icon;
|
|
final String text;
|
|
final bool chat;
|
|
final String thumb;
|
|
bool leaving = false;
|
|
ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false, this.thumb = ''});
|
|
}
|
|
|
|
/// Splits a Notifier toast into who and what: feed and chat toasts name
|
|
/// their [ToastItem.actor] and start with it ("Bob joined", "Bob: hi");
|
|
/// the rest are the app's.
|
|
ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
|
final n = t.actor;
|
|
if (n == null || n.isEmpty) return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline, thumb: t.thumb);
|
|
if (t.chat && t.text.startsWith('$n: ')) {
|
|
return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true);
|
|
}
|
|
final rest = t.text.startsWith('$n ') ? t.text.substring(n.length + 1) : t.text;
|
|
final icon = rest.startsWith('joined')
|
|
? Icons.login
|
|
: rest.startsWith('left')
|
|
? Icons.logout
|
|
: rest.startsWith('lost connection')
|
|
? Icons.wifi_off_rounded
|
|
: rest.startsWith('is back')
|
|
? Icons.wifi_rounded
|
|
: rest.startsWith('sat you out')
|
|
? Icons.bedtime
|
|
: rest.startsWith('nudged')
|
|
? Icons.notifications_active_outlined
|
|
: rest.startsWith('changed')
|
|
? Icons.swap_horiz
|
|
: rest.startsWith('added')
|
|
? Icons.playlist_add_rounded
|
|
: Icons.info_outline;
|
|
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon, thumb: t.thumb);
|
|
}
|
|
|
|
/// The Notifier's toasts as cards with avatars, bottom-left: each slides
|
|
/// in, stays while the Notifier keeps it (it drops it after its time),
|
|
/// then slides out and collapses its gap. A click on a chat toast opens
|
|
/// the chat ([onOpenChat]); the rest let clicks through. What pops up at
|
|
/// all (chat pop-ups, sitting out, alone) is the Notifier's policy.
|
|
class PartyToasts extends StatefulWidget {
|
|
final VoidCallback? 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<PartyToasts> createState() => _PartyToastsState();
|
|
}
|
|
|
|
class _PartyToastsState extends State<PartyToasts> {
|
|
final _shown = <ShellToast>[];
|
|
final _timers = <Timer>[];
|
|
Set<int> _seen = {};
|
|
late final void Function() _stop;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final coord = AppScope.of(context).coordinator;
|
|
// What was there before this mounted is old news.
|
|
_seen = {for (final t in coord.toasts.peek()) t.id};
|
|
_stop = effect(() {
|
|
final items = coord.toasts.value;
|
|
final ids = {for (final t in items) t.id};
|
|
final fresh = [for (final t in items) if (!_seen.contains(t.id)) t];
|
|
final gone = _seen.difference(ids);
|
|
_seen = ids;
|
|
if (fresh.isEmpty && gone.isEmpty) return;
|
|
// 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);
|
|
});
|
|
});
|
|
}
|
|
|
|
void _add(ShellToast t) {
|
|
// Not from inside the effect's build-time notification.
|
|
scheduleMicrotask(() {
|
|
if (mounted) setState(() => _shown.add(t));
|
|
});
|
|
}
|
|
|
|
void _leave(Set<int> ids) {
|
|
scheduleMicrotask(() {
|
|
if (!mounted) return;
|
|
final leaving = [for (final t in _shown) if (ids.contains(t.id) && !t.leaving) t];
|
|
if (leaving.isEmpty) return;
|
|
setState(() {
|
|
for (final t in leaving) {
|
|
t.leaving = true;
|
|
}
|
|
});
|
|
_timers.add(Timer(easeTime, () {
|
|
if (mounted) setState(() => _shown.removeWhere(leaving.contains));
|
|
}));
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_stop();
|
|
for (final t in _timers) {
|
|
t.cancel();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
void _open(ShellToast t) {
|
|
widget.onOpenChat?.call();
|
|
AppScope.of(context).notifier.dismiss(t.id);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => ToastColumn(
|
|
toasts: _shown.reversed.take(6).toList().reversed.toList(),
|
|
onTap: widget.onOpenChat == null ? null : _open,
|
|
);
|
|
}
|
|
|
|
class ToastColumn extends StatelessWidget {
|
|
final List<ShellToast> toasts;
|
|
/// Clicks on chat toasts; null (or any other toast): clicks pass through.
|
|
final ValueChanged<ShellToast>? onTap;
|
|
const ToastColumn({super.key, required this.toasts, this.onTap});
|
|
|
|
Widget _card(ShellToast t) {
|
|
final card = ToastCard(toast: t);
|
|
final tap = onTap;
|
|
if (!t.chat || tap == null || t.leaving) return IgnorePointer(child: card);
|
|
return MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
child: GestureDetector(
|
|
key: ValueKey('toast-open-chat-${t.id}'),
|
|
onTap: () => tap(t),
|
|
child: card,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
for (final t in toasts)
|
|
TweenAnimationBuilder<double>(
|
|
key: ValueKey(t.id),
|
|
tween: Tween(begin: 0, end: 1),
|
|
duration: easeTime,
|
|
curve: ease,
|
|
builder: (context, v, child) => Opacity(
|
|
opacity: v,
|
|
child: Transform.translate(offset: Offset(-24 * (1 - v), 0), child: child),
|
|
),
|
|
// Leaving: slide back out, fade, and collapse the gap it leaves.
|
|
child: AnimatedAlign(
|
|
duration: easeTime,
|
|
curve: ease,
|
|
alignment: Alignment.topLeft,
|
|
heightFactor: t.leaving ? 0 : 1,
|
|
child: AnimatedSlide(
|
|
duration: easeTime,
|
|
curve: ease,
|
|
offset: t.leaving ? const Offset(-0.25, 0) : Offset.zero,
|
|
child: AnimatedOpacity(
|
|
duration: easeTime,
|
|
curve: ease,
|
|
opacity: t.leaving ? 0 : 1,
|
|
child: Padding(padding: const EdgeInsets.only(top: 8), child: _card(t)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class ToastCard extends StatelessWidget {
|
|
final ShellToast toast;
|
|
const ToastCard({super.key, required this.toast});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final who = toast.who;
|
|
return Material(
|
|
elevation: 8,
|
|
color: scheme.surfaceContainerHigh.withValues(alpha: 0.95),
|
|
borderRadius: BorderRadius.circular(14),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 380),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(10, 10, 16, 10),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: who == null ? CrossAxisAlignment.center : CrossAxisAlignment.start,
|
|
children: [
|
|
if (who != null)
|
|
StatusAvatar(info: who, size: 38, badges: false)
|
|
else if (toast.thumb.isNotEmpty)
|
|
_Thumb(hash: toast.thumb)
|
|
else
|
|
CircleAvatar(
|
|
radius: 19,
|
|
backgroundColor: scheme.primary.withValues(alpha: 0.15),
|
|
child: Icon(toast.icon ?? Icons.info_outline, size: 18, color: scheme.primary),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Flexible(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (who != null) ...[
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Flexible(
|
|
child: Text(who.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15)),
|
|
),
|
|
if (toast.icon != null) ...[
|
|
const SizedBox(width: 6),
|
|
Icon(toast.icon, size: 16, color: scheme.primary),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 2),
|
|
],
|
|
Text(
|
|
toast.text,
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: toast.chat ? 15 : 14,
|
|
color: toast.chat || who == null ? scheme.onSurface : scheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (who != null && toast.thumb.isNotEmpty) ...[
|
|
const SizedBox(width: 12),
|
|
_Thumb(hash: toast.thumb),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// An Up next item's picture (16:9, from the queue's picture cache);
|
|
/// a blank until it is fetched.
|
|
class _Thumb extends StatelessWidget {
|
|
final String hash;
|
|
const _Thumb({required this.hash});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final thumbs = AppScope.of(context).coordinator.queue.thumbs;
|
|
return ClipRRect(
|
|
key: const ValueKey('toast-thumb'),
|
|
borderRadius: BorderRadius.circular(6),
|
|
child: SizedBox(
|
|
width: 68,
|
|
height: 38,
|
|
child: ListenableBuilder(
|
|
listenable: thumbs,
|
|
builder: (context, _) {
|
|
final bytes = thumbs.bytesFor(hash);
|
|
if (bytes == null) return ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest);
|
|
return Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|