Files
sync_replay_project/apps/desktop/lib/src/shell/chat_panel.dart
T
foxyzandClaude Opus 5.5 d29c12e4f0 1.0 shell: one window with a party strip; protocol 9 profile + avatars by hash
The shell (lib/src/shell/) replaces the Home and session screens:
- Party strip: profile, Library / Schedule / Settings tabs, Join and
  Host cards (hosting no longer takes media), status-ring avatar stack
  with the member list, chat & activity panel, Leave.
- The real session player is mounted once and shown full, as a
  draggable corner-snapping mini player, or fullscreen (window_manager,
  party and toasts overlaid); the Video is never mounted twice.
- Profile popover with inline rename, colour and a crop picker (256 px
  PNG), pushed live to the session; avatar toasts; waiting pill.
- Motion kit (fastEaseInToSlowEaseOut, fade-through, popovers,
  mouse-drag/wheel horizontal scrolling).
- Library = the existing picker for now (restyled next round); Schedule
  = the new SchedulePage; Settings = left nav over the existing panels.

Protocol 9 (packages/core, packages/server, sync client):
- {"t":"profile"} renames / changes the picture mid-session ("rename",
  "avatar" feed actions); names are cleaned the same way at hello.
- state.members carry avatarHash; pictures are served once from
  /a/<sid>/<token>/<hash> (PNG/JPEG/WebP/GIF by magic bytes only) and
  cached by hash in the app (AvatarCache).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:23:46 +03:00

184 lines
6.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../state/session_state.dart';
import 'avatar.dart';
/// Side panel: chat with avatars, other activity as quiet lines, and the
/// message field. Width is fixed; the shell slides it in and out.
class ChatPanel extends StatelessWidget {
static const width = 340.0;
final TextEditingController chat;
final FocusNode chatFocus;
final VoidCallback onClose;
const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose});
void _send() {
SessionState.instance.coordinator.sendChat(chat.text);
chat.clear();
chatFocus.requestFocus();
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
key: const ValueKey('chat-panel'),
width: width,
color: scheme.surfaceContainerLow,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
child: Row(
children: [
const Text('Chat & activity', style: TextStyle(fontWeight: FontWeight.w600)),
const Spacer(),
IconButton(tooltip: 'Close (Tab)', onPressed: onClose, icon: const Icon(Icons.close, size: 18)),
],
),
),
Expanded(
child: PartyWatch(builder: (context) {
final log = SessionState.instance.coordinator.feedLog.value;
if (log.isEmpty) {
return Center(
child: Text('Nothing yet', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
);
}
return ListView.builder(
reverse: true,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: log.length,
itemBuilder: (context, i) => ActivityRow(item: log[log.length - 1 - i]),
);
}),
),
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
key: const ValueKey('chat-field'),
controller: chat,
focusNode: chatFocus,
onSubmitted: (_) => _send(),
decoration: InputDecoration(
hintText: 'Message (Enter)',
isDense: true,
filled: true,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
suffixIcon: IconButton(tooltip: 'Send', onPressed: _send, icon: const Icon(Icons.send, size: 18)),
),
),
),
],
),
);
}
}
/// One feed line: a chat message with the sender's avatar, anything else
/// as an icon and "who did what".
class ActivityRow extends StatelessWidget {
final FeedMessage item;
const ActivityRow({super.key, required this.item});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final time = _clock(item.at);
if (item.action == 'chat') {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StatusAvatar(info: avatarForName(item.actor), size: 34, badges: false),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(item.actor,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700)),
),
const SizedBox(width: 6),
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
],
),
SelectableText(item.reason),
],
),
),
],
),
);
}
final (icon, color, text) = item.actor == 'server' ? serverFeedStyle(item) : feedStyle(item, scheme);
final line = item.actor == 'server' ? text : '${item.actor} $text';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
const SizedBox(width: 8),
Icon(icon, size: 14, color: color),
const SizedBox(width: 8),
Expanded(child: Text(line, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12))),
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
],
),
);
}
}
/// Icon, colour and wording of a member's feed action.
(IconData, Color, String) feedStyle(FeedMessage f, ColorScheme scheme) {
switch (f.action) {
case 'join':
return (Icons.login, Colors.green, 'joined');
case 'leave':
final reason = f.reason == 'silent too long' ? ' · timed out' : (f.reason.isEmpty ? '' : ' · ${f.reason}');
return (Icons.logout, Colors.grey, 'left$reason');
case 'ready':
return (Icons.check_circle, Colors.green, 'is ready');
case 'unready':
return (Icons.radio_button_unchecked, Colors.grey, "isn't ready");
case 'force-ready':
return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} ready');
case 'force-unready':
return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} not ready');
case 'force-play':
return (Icons.play_arrow, Colors.green, 'started playback for everyone');
case 'force-hold':
return (Icons.pause, Colors.orange, 'held playback for everyone');
case 'seek':
return (Icons.fast_forward, scheme.primary, 'seeked');
case 'swap':
return (Icons.swap_horiz, scheme.primary, 'changed the media');
case 'stale':
return (Icons.cloud_off, Colors.orange, 'lost signal');
case 'kick':
final reason = f.reason.isEmpty ? '' : ' · ${f.reason}';
return (Icons.gavel, Colors.red, 'kicked ${f.target ?? ''}$reason');
default:
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
return (Icons.info_outline, Colors.grey, '${f.action}$target');
}
}
/// The server's own lines (pause / resume).
(IconData, Color, String) serverFeedStyle(FeedMessage f) => switch (f.action) {
'resume' => (Icons.play_arrow, Colors.green, 'Playback resumed'),
_ => (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness'),
};
String _clock(DateTime at) {
final l = at.toLocal();
String two(int n) => n.toString().padLeft(2, '0');
return '${two(l.hour)}:${two(l.minute)}';
}