Files
sync_replay_project/apps/desktop/lib/src/screens/session_screen.dart
T
foxyz ff154c5030 Desktop session batch 09-12: frameless chrome, bisect removal, chat/idle fixes
Frameless (window_manager, hidden titlebar): chrome bar on welcome,
drag-to-move appbar + window buttons on connect, drag layer + window
buttons in session controls; min size 160x90; volume-scroll off for
drag; session maximize = fullscreen toggle. Verified on KDE Wayland.
Delete legacy/minimal/nosheet bisect screens; session routes direct
(black-screen fix verified incl. WAN).
Avatar: pipeline now 128px aspect-kept PNG (was cropped JPEG);
profile card centered 64px hover-to-change; member avatar drops
ResizeImage downscale. NOTE open: pipeline 512 KiB check vs server
64 KiB enforcement mismatch.
Fixes: idle warn/unready skipped while PLAYING; sendChat drops
blanks; one chat row per send (drop textless server feed echo);
chat field keeps focus after send. Playback libass true; sdk floor
3.13. Handoff 9-11: 9+10 fixed, 11 jumpy seek still open.
Docs: handoff/roadmap/protocol record all of the above; few-commits
preference going forward.
2026-09-12 11:42:18 +03:00

937 lines
32 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../session/health.dart';
import '../state/session_state.dart';
import '../widgets/dialogs.dart';
import '../widgets/member_avatar.dart';
import '../widgets/window_buttons.dart';
import '../widgets/window_drag_layer.dart';
class SessionScreen extends StatefulWidget {
const SessionScreen({super.key});
@override
State<SessionScreen> createState() => _SessionScreenState();
}
class _SessionScreenState extends State<SessionScreen> {
VideoController? _video;
final _videoKey = GlobalKey<VideoState>();
final _sheetOpen = signal(false);
final _confirmLeave = signal(false);
final _isFullscreen = signal(false);
DateTime? _lastKeyAt;
final _chat = TextEditingController();
final _chatFocus = FocusNode();
final _swapLink = TextEditingController();
late final void Function() _stopKickWatch;
late final void Function() _stopSheetWatch;
late final FocusNode _noFocus;
@override
void initState() {
super.initState();
final session = SessionState.instance;
_noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
_video = VideoController(session.coordinator.playback.player);
HardwareKeyboard.instance.addHandler(_globalKey);
_stopKickWatch = effect(() {
if (session.coordinator.kickReason.value != null) {
session.route.value = AppRoute.connect;
}
});
_stopSheetWatch = effect(() {
if (!_sheetOpen.value) {
FocusManager.instance.primaryFocus?.unfocus();
}
});
}
void _toggleFullscreen() {
if (_isFullscreen.peek()) {
if (mounted) {
Navigator.of(context, rootNavigator: true).maybePop();
}
} else {
_videoKey.currentState?.enterFullscreen();
}
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_globalKey);
_stopKickWatch();
_stopSheetWatch();
_chat.dispose();
_chatFocus.dispose();
_swapLink.dispose();
_noFocus.dispose();
super.dispose();
}
bool get _editing {
var n = FocusManager.instance.primaryFocus;
while (n != null) {
final w = n.context?.widget;
if (w is EditableText) return true;
n = n.parent;
}
return false;
}
bool _globalKey(KeyEvent event) {
if (event is! KeyDownEvent) return false;
if (SessionState.instance.route.peek() != AppRoute.session) return false;
if (_editing) return false;
final now = DateTime.now().toUtc();
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
return false;
}
final coord = SessionState.instance.coordinator;
if (event.logicalKey == LogicalKeyboardKey.space) {
_lastKeyAt = now;
coord.toggleReady();
return true;
}
if (event.logicalKey == LogicalKeyboardKey.tab) {
_lastKeyAt = now;
_sheetOpen.value = !_sheetOpen.peek();
coord.markInput();
return true;
}
if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
_lastKeyAt = now;
coord.nudge(10);
return true;
}
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
_lastKeyAt = now;
coord.nudge(-10);
return true;
}
if (event.logicalKey == LogicalKeyboardKey.escape) {
if (_sheetOpen.peek()) {
_sheetOpen.value = false;
return true;
}
if (_confirmLeave.peek()) {
_confirmLeave.value = false;
return true;
}
if (_isFullscreen.peek()) {
if (mounted) {
Navigator.of(context, rootNavigator: true).maybePop();
}
return true;
}
_lastKeyAt = now;
_confirmLeave.value = true;
return true;
}
if (event.logicalKey == LogicalKeyboardKey.keyF) {
_lastKeyAt = now;
if (_isFullscreen.peek()) {
if (mounted) {
Navigator.of(context, rootNavigator: true).maybePop();
}
} else {
_videoKey.currentState?.enterFullscreen();
}
return true;
}
return false;
}
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
final controlsTheme = MaterialDesktopVideoControlsThemeData(
playAndPauseOnTap: false,
modifyVolumeOnScroll: false,
keyboardShortcuts: {},
visibleOnMount: true,
hideMouseOnControlsRemoval: true,
controlsHoverDuration: const Duration(seconds: 3),
topButtonBar: [
const Spacer(),
MaterialDesktopCustomButton(
icon: const Icon(Icons.group),
onPressed: () {
_sheetOpen.value = true;
coord.markInput();
},
),
SignalBuilder(
builder: (context) => WindowButtons(focusNode: _noFocus, fullscreen: _isFullscreen.value, onToggleFullscreen: _toggleFullscreen),
),
],
bottomButtonBar: [
const MaterialDesktopVolumeButton(),
_ReadyControlButton(focusNode: _noFocus),
_MarkAllReadyControlButton(focusNode: _noFocus),
const MaterialDesktopPositionIndicator(),
const Spacer(),
const MaterialDesktopFullscreenButton(),
],
);
return FocusTraversalGroup(
descendantsAreTraversable: false,
child: Scaffold(
backgroundColor: Colors.black,
body: _video == null
? const Center(
child: Text('player starting…', style: TextStyle(color: Colors.white)),
)
: MaterialDesktopVideoControlsTheme(
normal: controlsTheme,
fullscreen: controlsTheme,
child: Video(
key: _videoKey,
controller: _video!,
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
controls: _sessionControls,
onEnterFullscreen: () async {
_isFullscreen.value = true;
await defaultEnterNativeFullscreen();
},
onExitFullscreen: () async {
_isFullscreen.value = false;
await defaultExitNativeFullscreen();
},
),
),
),
);
}
Widget _sessionControls(VideoState state) {
return Stack(
children: [
MaterialDesktopVideoControls(state),
_healthOverlay(),
_waitingOverlay(),
_toastsOverlay(),
_sheetOverlay(),
_leaveOverlay(),
Positioned.fill(
child: SignalBuilder(
builder: (context) {
final sheetOpen = _sheetOpen.value;
final confirm = _confirmLeave.value;
return WindowDragLayer(
enabled: !confirm,
exclusions: [
(s) => Rect.fromLTWH(0, s.height - 120, s.width, 120),
if (sheetOpen) (s) => Rect.fromLTWH(s.width - 380, 0, 380, s.height),
],
);
},
),
),
],
);
}
Widget _healthOverlay() {
final coord = SessionState.instance.coordinator;
return Positioned(
top: 12,
left: 12,
child: SignalBuilder(
builder: (context) {
final h = coord.health.value;
final starve = coord.starving.value;
final retrying = coord.reconnecting.value;
final kicked = coord.kickReason.value;
final unwell = h.severity == HealthSeverity.warn || h.severity == HealthSeverity.error || retrying || starve != null;
if (!unwell) return const SizedBox.shrink();
final color = switch (h.severity) {
HealthSeverity.ok => Colors.green,
HealthSeverity.info => Colors.grey,
HealthSeverity.warn => Colors.orange,
HealthSeverity.error => Colors.red,
};
final IconData icon;
if (retrying) {
icon = Icons.sync;
} else if (h.severity == HealthSeverity.error) {
icon = Icons.error_outline;
} else if (h.severity == HealthSeverity.warn) {
icon = Icons.warning_amber_outlined;
} else {
icon = Icons.hourglass_empty;
}
final text = [h.message, if (starve != null) starve, if (retrying) 'reconnecting…'].join(' · ');
final canRetry = !retrying && kicked == null && (h.severity == HealthSeverity.error || h.severity == HealthSeverity.warn);
return Container(
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(999)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 8),
Flexible(
child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 12)),
),
if (retrying)
TextButton(
style: TextButton.styleFrom(foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap),
onPressed: () => coord.cancelReconnect(),
child: const Text('Cancel', style: TextStyle(fontSize: 12)),
),
if (canRetry)
TextButton(
style: TextButton.styleFrom(foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap),
onPressed: () => coord.retry(),
child: const Text('Retry', style: TextStyle(fontSize: 12)),
),
],
),
);
},
),
);
}
Widget _waitingOverlay() {
final coord = SessionState.instance.coordinator;
return SignalBuilder(
builder: (context) {
final r = coord.room.value;
final waiting = [
for (final m in r?.members ?? [])
if (!m.ready) m,
];
final show = r != null && r.transport == 'held' && waiting.isNotEmpty;
final selfReady = coord.selfMember.value?.ready ?? false;
return Positioned.fill(
child: Center(
child: Visibility(
visible: show,
maintainState: true,
maintainAnimation: true,
maintainSize: true,
child: Column(
mainAxisSize: .min,
spacing: 12,
children: [
IgnorePointer(
child: Container(
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.7), borderRadius: BorderRadiusGeometry.circular(20)),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: .min,
spacing: 10,
crossAxisAlignment: .center,
children: [
Text("Waiting for", style: TextTheme.of(context).labelLarge),
Wrap(
alignment: WrapAlignment.center,
spacing: -20,
runSpacing: 8,
children: [
for (final m in waiting)
Opacity(
opacity: m.stale ? 0.5 : 1.0,
child: MemberAvatar(avatarBase64: m.avatar, name: m.name, size: 96),
),
],
),
],
),
),
),
),
if (!selfReady)
FilledButton(
focusNode: _noFocus,
onPressed: () => coord.toggleReady(),
child: const Text("I'm Ready"),
),
],
),
),
),
);
},
);
}
Widget _toastsOverlay() {
final coord = SessionState.instance.coordinator;
return Positioned(
right: 12,
top: 56,
child: IgnorePointer(
child: SignalBuilder(
builder: (context) {
final items = coord.toasts.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [for (final t in items.reversed.take(5)) _ToastCard(key: ValueKey(t.id), text: t.text)],
);
},
),
),
);
}
Widget _sheetOverlay() {
return SignalBuilder(
builder: (context) {
final open = _sheetOpen.value;
return ExcludeFocus(
excluding: !open,
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: IgnorePointer(
ignoring: !open,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: open ? 1 : 0,
child: GestureDetector(
onTap: () => _sheetOpen.value = false,
child: Container(color: Colors.black54),
),
),
),
),
Positioned(
top: 20,
bottom: 20,
right: 0,
child: IgnorePointer(
ignoring: !open,
child: AnimatedSlide(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
offset: open ? Offset.zero : const Offset(1, 0),
child: SizedBox(
width: 380,
height: double.infinity,
child: _SheetPanel(
chat: _chat,
chatFocus: _chatFocus,
swapLink: _swapLink,
onClose: () => _sheetOpen.value = false,
onLeave: () {
_sheetOpen.value = false;
_confirmLeave.value = true;
},
),
),
),
),
),
],
),
);
},
);
}
Widget _leaveOverlay() {
final session = SessionState.instance;
return SignalBuilder(
builder: (context) {
if (!_confirmLeave.value) return const SizedBox.shrink();
return Positioned.fill(
child: GestureDetector(
onTap: () => _confirmLeave.value = false,
child: Container(
color: Colors.black54,
child: Center(
child: GestureDetector(
onTap: () {},
child: AlertDialog(
title: const Text('Leave room?'),
content: const Text('You can rejoin right away.'),
actions: [
TextButton(onPressed: () => _confirmLeave.value = false, child: const Text('Cancel')),
TextButton(
onPressed: () {
_confirmLeave.value = false;
session.leave();
},
child: const Text('Leave'),
),
],
),
),
),
),
),
);
},
);
}
}
class _ReadyControlButton extends StatelessWidget {
final FocusNode focusNode;
const _ReadyControlButton({required this.focusNode});
@override
Widget build(BuildContext context) {
return SignalBuilder(
builder: (context) {
final ready = SessionState.instance.coordinator.selfMember.value?.ready ?? false;
return TextButton(
focusNode: focusNode,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => SessionState.instance.coordinator.toggleReady(),
child: Text(ready ? 'Ready ✓' : 'Not ready', style: const TextStyle(fontSize: 12)),
);
},
);
}
}
class _MarkAllReadyControlButton extends StatelessWidget {
final FocusNode focusNode;
const _MarkAllReadyControlButton({required this.focusNode});
@override
Widget build(BuildContext context) {
return TextButton(
focusNode: focusNode,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => SessionState.instance.coordinator.forcePlayAll(),
child: const Text('Mark everyone ready', style: TextStyle(fontSize: 12)),
);
}
}
class _ToastCard extends StatefulWidget {
final String text;
const _ToastCard({super.key, required this.text});
@override
State<_ToastCard> createState() => _ToastCardState();
}
class _ToastCardState extends State<_ToastCard> {
bool _shown = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _shown = true);
});
}
@override
Widget build(BuildContext context) {
return AnimatedSlide(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
offset: _shown ? Offset.zero : const Offset(1, 0),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 250),
opacity: _shown ? 1 : 0,
child: Card(
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Text(widget.text)),
),
),
);
}
}
class _SheetPanel extends StatelessWidget {
final TextEditingController chat;
final FocusNode chatFocus;
final TextEditingController swapLink;
final VoidCallback onClose;
final VoidCallback onLeave;
const _SheetPanel({required this.chat, required this.chatFocus, required this.swapLink, required this.onClose, required this.onLeave});
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
return Material(
borderRadius: BorderRadius.horizontal(left: .circular(20)),
elevation: 8,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: SignalBuilder(
builder: (context) {
final r = coord.room.value;
final log = coord.feedLog.value;
final theme = Theme.of(context);
final sectionStyle = theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold);
final members = r?.members ?? const <MemberView>[];
final readyCount = members.where((m) => m.ready).length;
final playing = r?.transport == 'playing';
return Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Room', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
_StatusChip(playing: playing, hasRoom: r != null),
],
),
const SizedBox(height: 2),
Text(
r == null ? 'connecting…' : '${members.length} member${members.length == 1 ? '' : 's'} · $readyCount ready',
style: theme.textTheme.bodySmall,
),
],
),
),
IconButton(icon: const Icon(Icons.close), onPressed: onClose),
],
),
const Divider(height: 20),
Text('Users (${members.length})', style: sectionStyle),
const SizedBox(height: 2),
for (final m in members) _MemberRow(member: m, isSelf: m.id == coord.sync.memberId),
const Divider(height: 20),
Text('Activity', style: sectionStyle),
const SizedBox(height: 6),
if (log.isEmpty) Text('No events yet', style: theme.textTheme.bodySmall),
Expanded(
child: ListView.builder(
reverse: true,
itemBuilder: (context, index) => _ActivityRow(item: log.reversed.toList()[index]),
itemCount: log.length,
),
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: chat,
focusNode: chatFocus,
decoration: const InputDecoration(labelText: 'Chat message', border: OutlineInputBorder(), isDense: true),
onSubmitted: (v) {
coord.sendChat(v);
chat.clear();
chatFocus.requestFocus();
},
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
onPressed: () {
coord.sendChat(chat.text);
chat.clear();
chatFocus.requestFocus();
},
),
],
),
const SizedBox(height: 12),
Text('Share a link', style: sectionStyle),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: swapLink,
decoration: const InputDecoration(labelText: 'Swap to link URL', border: OutlineInputBorder(), isDense: true),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.swap_horiz),
onPressed: () {
coord.swapLink(swapLink.text.trim());
swapLink.clear();
},
),
],
),
const Divider(height: 20),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(foregroundColor: theme.colorScheme.error),
icon: const Icon(Icons.logout),
label: const Text('Leave room'),
onPressed: onLeave,
),
),
],
);
},
),
),
);
}
}
class _StatusChip extends StatelessWidget {
final bool playing;
final bool hasRoom;
const _StatusChip({required this.playing, required this.hasRoom});
@override
Widget build(BuildContext context) {
final Color dot;
final String label;
if (!hasRoom) {
dot = Colors.grey;
label = '…';
} else if (playing) {
dot = Colors.green;
label = 'Playing';
} else {
dot = Colors.orange;
label = 'Waiting';
}
return Container(
decoration: BoxDecoration(color: Colors.white10, borderRadius: BorderRadius.circular(999)),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(shape: BoxShape.circle, color: dot),
),
const SizedBox(width: 6),
Text(label, style: Theme.of(context).textTheme.labelSmall),
],
),
);
}
}
class _MemberRow extends StatelessWidget {
final MemberView member;
final bool isSelf;
const _MemberRow({required this.member, required this.isSelf});
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
final theme = Theme.of(context);
final status = _memberStatus(member);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
MemberAvatar(avatarBase64: member.avatar, name: member.name, size: 32),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isSelf ? '${member.name} (you)' : member.name,
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(shape: BoxShape.circle, color: status.$2),
),
const SizedBox(width: 6),
Text(
status.$1,
style: theme.textTheme.bodySmall?.copyWith(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.75)),
),
],
),
],
),
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 18),
padding: EdgeInsets.zero,
onSelected: (v) async {
if (v == 'force') {
coord.forceTarget(member.id, !member.ready);
} else if (v == 'kick') {
final ok = await showKickConfirmDialog(context, member.name);
if (ok == true) coord.kick(member.id);
}
},
itemBuilder: (_) => [
PopupMenuItem(value: 'force', child: Text(member.ready ? 'Force unready' : 'Force ready')),
PopupMenuItem(
value: 'kick',
child: Text('Kick', style: TextStyle(color: theme.colorScheme.error)),
),
],
),
],
),
);
}
}
(String, Color) _memberStatus(MemberView m) {
if (m.stale) return ('Stale', Colors.orange);
if (!m.ready) return ('Not ready', Colors.grey);
if (m.buffered < RelayConstants.minBufferSecs) return ('Buffering ${m.buffered.toStringAsFixed(0)}s', Colors.orange);
return ('Ready', Colors.green);
}
class _ActivityRow extends StatelessWidget {
final FeedMessage item;
const _ActivityRow({required this.item});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final time = _clock(item.at);
final subtle = theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6) ?? Colors.grey;
if (item.action == 'chat') {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.chat_bubble_outline, size: 15, color: Colors.grey),
const SizedBox(width: 8),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: '${item.actor} ',
style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold),
),
TextSpan(text: item.reason, style: theme.textTheme.bodySmall),
],
),
),
),
const SizedBox(width: 8),
Text(time, style: theme.textTheme.labelSmall?.copyWith(color: subtle)),
],
),
);
}
if (item.actor == 'server') {
final style = _serverFeedStyle(item);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(style.$1, size: 15, color: style.$2),
const SizedBox(width: 8),
Expanded(child: Text(style.$3, style: theme.textTheme.bodySmall)),
const SizedBox(width: 8),
Text(time, style: theme.textTheme.labelSmall?.copyWith(color: subtle)),
],
),
);
}
final style = _feedStyle(item, theme);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(style.$1, size: 15, color: style.$2),
const SizedBox(width: 8),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: item.actor,
style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold),
),
TextSpan(text: ' ${style.$3}', style: theme.textTheme.bodySmall),
],
),
),
),
const SizedBox(width: 8),
Text(time, style: theme.textTheme.labelSmall?.copyWith(color: subtle)),
],
),
);
}
}
(IconData, Color, String) _feedStyle(FeedMessage f, ThemeData theme) {
switch (f.action) {
case 'join':
return (Icons.person_add, 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, theme.colorScheme.primary, 'seeked');
case 'swap':
return (Icons.movie, theme.colorScheme.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');
}
}
(IconData, Color, String) _serverFeedStyle(FeedMessage f) {
switch (f.action) {
case 'resume':
return (Icons.play_arrow, Colors.green, 'Playback resumed');
default:
return (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)}';
}