feat: stabilize voice activity and audio routing

This commit is contained in:
Edison Jwa
2026-05-25 01:19:09 +09:00
parent eb9014cd81
commit 5515ff6643
34 changed files with 3054 additions and 1751 deletions
+424 -29
View File
@@ -10,6 +10,9 @@ import '../services/ts3_server_link.dart';
import '../src/rust/api.dart' as rust;
import 'bbcode_text.dart';
const double _chatSidebarTileExtent = 92;
const Color _chatSidebarSelectedTileColor = Color(0xFF415366);
/// One chat/activity message shown in the chat hub.
class ChatEntry {
/// Construct a chat entry.
@@ -20,6 +23,7 @@ class ChatEntry {
required this.target,
this.isSelf = false,
this.timestamp,
this.countsTowardUnread = true,
});
/// Sender client id.
@@ -40,11 +44,18 @@ class ChatEntry {
/// Local time when this message was received or sent.
final DateTime? timestamp;
/// Whether this entry should increment the message unread badge.
final bool countsTowardUnread;
/// True for direct-message targets.
bool get isPrivate => target is rust.BridgeMessageTarget_Client;
/// True for poke targets.
bool get isPoke => target is rust.BridgeMessageTarget_Poke;
/// True for synthetic or protocol-driven server activity rows.
bool get isServerActivity =>
target is rust.BridgeMessageTarget_Server && !countsTowardUnread;
}
String chatTimeLabel(DateTime? timestamp) {
@@ -69,6 +80,162 @@ String pokeHistoryLine(AppL10n l10n, ChatEntry entry) {
return l10n.pokeHistoryIncomingWithMessage(time, peerName, message);
}
enum _ActivityTone {
positive,
transition,
warning,
plain,
username,
channel,
adminActor,
automationActor,
group,
}
sealed class _ActivitySegment {
const _ActivitySegment(this.text, this.tone, {this.bold = false});
final String text;
final _ActivityTone tone;
final bool bold;
}
class _ActivityTextSegment extends _ActivitySegment {
const _ActivityTextSegment(super.text, super.tone, {super.bold = false});
}
List<_ActivitySegment> _activitySegments(String message) {
final quotedMatches = RegExp(r'"[^"]+"').allMatches(message).toList();
if (quotedMatches.isEmpty) {
return [_ActivityTextSegment(message, _activityActionTone(message))];
}
final segments = <_ActivitySegment>[];
var cursor = 0;
for (var i = 0; i < quotedMatches.length; i++) {
final match = quotedMatches[i];
if (match.start > cursor) {
final plain = message.substring(cursor, match.start);
if (plain.isNotEmpty) {
segments.add(
_ActivityTextSegment(plain, _activityActionTone(plain.trim())),
);
}
}
final quoted = match.group(0)!;
segments.add(_activityEntitySegment(quoted, message, i));
cursor = match.end;
}
if (cursor < message.length) {
final tail = message.substring(cursor);
if (tail.isNotEmpty) {
segments.add(
_ActivityTextSegment(tail, _activityActionTone(tail.trim())),
);
}
}
return segments;
}
_ActivitySegment _activityEntitySegment(
String quoted,
String fullMessage,
int quotedIndex,
) {
final lower = fullMessage.toLowerCase();
final after = fullMessage
.substring(fullMessage.indexOf(quoted) + quoted.length)
.toLowerCase();
final value = quoted.substring(1, quoted.length - 1);
if (lower.contains('channel group ')) {
if (quotedIndex == 0) {
return _ActivityTextSegment(quoted, _ActivityTone.group);
}
return _activityActorSegment(value, quoted);
}
if (lower.contains('server group ')) {
if (quotedIndex == 0) {
return _ActivityTextSegment(quoted, _ActivityTone.group);
}
return _activityActorSegment(value, quoted);
}
if (after.startsWith(' connected to channel') ||
after.startsWith(' switched from channel') ||
after.startsWith(' disconnected') ||
after.startsWith(' was moved from channel') ||
after.startsWith(' is now ')) {
return _activityActorSegment(value, quoted);
}
if (lower.contains('channel ') &&
(after.startsWith('.') ||
after.startsWith(' to') ||
after.startsWith(' from') ||
after.startsWith(' by') ||
after.isEmpty)) {
return _ActivityTextSegment(quoted, _ActivityTone.channel, bold: true);
}
if (lower.contains('channel ') &&
(quotedIndex == 1 || quotedIndex == 2 || quotedIndex == 3)) {
return _ActivityTextSegment(quoted, _ActivityTone.channel, bold: true);
}
return _activityActorSegment(value, quoted);
}
_ActivitySegment _activityActorSegment(String value, String quoted) {
final lower = value.toLowerCase();
if (lower.contains('auto') ||
lower.contains('automation') ||
lower.contains('bot')) {
return _ActivityTextSegment(
quoted,
_ActivityTone.automationActor,
bold: true,
);
}
if (lower.contains('server') ||
lower.contains('admin') ||
lower.contains('vigorous pro')) {
return _ActivityTextSegment(quoted, _ActivityTone.adminActor, bold: true);
}
return _ActivityTextSegment(quoted, _ActivityTone.username, bold: true);
}
_ActivityTone _activityActionTone(String text) {
final lower = text.toLowerCase();
if (lower.contains('connected')) return _ActivityTone.positive;
if (lower.contains('disconnected') ||
lower.contains('dropped') ||
lower.contains('lost') ||
lower.contains('shutdown')) {
return _ActivityTone.warning;
}
if (lower.contains('switched') ||
lower.contains('moved') ||
lower.contains('created') ||
lower.contains('deleted') ||
lower.contains('renamed') ||
lower.contains('assigned') ||
lower.contains('removed')) {
return _ActivityTone.transition;
}
return _ActivityTone.plain;
}
Color _activityColor(BuildContext context, _ActivityTone tone) {
return switch (tone) {
_ActivityTone.positive => const Color(0xFF2F7D57),
_ActivityTone.transition => const Color(0xFF5A7394),
_ActivityTone.warning => const Color(0xFFC06A2B),
_ActivityTone.plain => const Color(0xFF6C7C8F),
_ActivityTone.username => const Color(0xFFC64A4A),
_ActivityTone.channel => const Color(0xFF3B6EA8),
_ActivityTone.adminActor => const Color(0xFF426B9A),
_ActivityTone.automationActor => const Color(0xFFB64C4C),
_ActivityTone.group => const Color(0xFF8B7A68),
};
}
/// Resolve the best chat target to show when opening the chat page.
///
/// Returns null to show the chat hub.
@@ -76,15 +243,126 @@ rust.BridgeMessageTarget? resolveInitialChatTarget({
required List<ChatEntry> messages,
required BigInt? currentVoiceChannelId,
}) {
bool hasServerActivity = false;
for (final message in messages.reversed) {
if (message.isPrivate) return message.target;
if (message.target is rust.BridgeMessageTarget_Server) {
hasServerActivity = true;
}
}
if (currentVoiceChannelId != null) {
return const rust.BridgeMessageTarget.channel();
}
if (hasServerActivity) {
return const rust.BridgeMessageTarget.server();
}
return null;
}
/// Build server-activity entries from a fresh snapshot diff.
List<ChatEntry> buildServerActivityEntries({
required rust.BridgeSnapshot previous,
required rust.BridgeSnapshot current,
DateTime? timestamp,
}) {
final now = timestamp ?? DateTime.now();
final previousClients = {
for (final client in previous.clients) client.id: client,
};
final currentClients = {
for (final client in current.clients) client.id: client,
};
final previousChannels = {
for (final channel in previous.channels) channel.id: channel,
};
final currentChannels = {
for (final channel in current.channels) channel.id: channel,
};
final entries = <ChatEntry>[];
String clientLabel(rust.BridgeClient client, rust.BridgeSnapshot snapshot) {
if (client.id == snapshot.ownClientId) return 'You';
final name = client.name.isNotEmpty ? client.name : 'Unknown';
return '"$name"';
}
String channelLabel(rust.BridgeSnapshot snapshot, BigInt channelId) {
final name = snapshotChannelName(snapshot, channelId);
if (name.isNotEmpty) return 'channel "$name"';
return 'channel ${channelId.toString()}';
}
void addEntry(String message) {
entries.add(
ChatEntry(
senderId: BigInt.zero,
senderName: 'Server',
message: message,
target: const rust.BridgeMessageTarget.server(),
timestamp: now,
countsTowardUnread: false,
),
);
}
for (final client in current.clients) {
if (client.isServerQuery) continue;
final previousClient = previousClients[client.id];
if (previousClient == null) {
addEntry(
'${clientLabel(client, current)} connected to ${channelLabel(current, client.channel)}',
);
continue;
}
if (previousClient.channel != client.channel) {
final fromChannel = channelLabel(previous, previousClient.channel);
final toChannel = channelLabel(current, client.channel);
addEntry(
'${clientLabel(client, current)} switched from $fromChannel to $toChannel',
);
continue;
}
if (previousClient.name != client.name && client.name.isNotEmpty) {
final previousName = previousClient.id == current.ownClientId
? 'You'
: (previousClient.name.isNotEmpty ? previousClient.name : 'Unknown');
final currentName = clientLabel(client, current);
addEntry('$previousName is now $currentName');
}
}
for (final client in previous.clients) {
if (client.isServerQuery) continue;
if (currentClients.containsKey(client.id)) continue;
addEntry(
'${clientLabel(client, previous)} disconnected from ${channelLabel(previous, client.channel)}',
);
}
for (final channel in current.channels) {
final previousChannel = previousChannels[channel.id];
if (previousChannel == null) {
addEntry('Channel created: ${channelLabel(current, channel.id)}');
continue;
}
if (previousChannel.name != channel.name) {
addEntry('Channel renamed to ${channelLabel(current, channel.id)}');
continue;
}
if (previousChannel.parent != channel.parent ||
previousChannel.order != channel.order) {
addEntry('Channel moved: ${channelLabel(current, channel.id)}');
}
}
for (final channel in previous.channels) {
if (currentChannels.containsKey(channel.id)) continue;
addEntry('Channel deleted: ${channelLabel(previous, channel.id)}');
}
return entries;
}
class ChatClientGroups {
const ChatClientGroups({
required this.clientsByChannel,
@@ -255,6 +533,9 @@ class ChatPage extends StatefulWidget {
super.key,
required this.messages,
required this.snapshot,
this.messagesSource,
this.snapshotSource,
this.refreshListenable,
this.initialTarget,
this.initialClientName = '',
this.onTs3ServerLink,
@@ -266,6 +547,15 @@ class ChatPage extends StatefulWidget {
/// Latest TeamSpeak snapshot.
final rust.BridgeSnapshot snapshot;
/// Optional live source for messages while this route stays open.
final List<ChatEntry> Function()? messagesSource;
/// Optional live source for snapshots while this route stays open.
final rust.BridgeSnapshot Function()? snapshotSource;
/// Triggers a rebuild when the backing chat state changes.
final Listenable? refreshListenable;
/// Non-null to open a specific target directly; null for hub.
final rust.BridgeMessageTarget? initialTarget;
@@ -284,13 +574,19 @@ class _ChatPageState extends State<ChatPage> {
String _selectedClientName = '';
final Set<BigInt> _closedPrivateChats = {};
List<ChatEntry> get _messages =>
widget.messagesSource?.call() ?? widget.messages;
rust.BridgeSnapshot get _snapshot =>
widget.snapshotSource?.call() ?? widget.snapshot;
@override
void initState() {
super.initState();
_selectedTarget =
widget.initialTarget ??
resolveInitialChatTarget(
messages: widget.messages,
messages: _messages,
currentVoiceChannelId: _currentChannelId,
) ??
const rust.BridgeMessageTarget.server();
@@ -298,16 +594,28 @@ class _ChatPageState extends State<ChatPage> {
}
BigInt? get _currentChannelId {
return ownClientSnapshotState(widget.snapshot)?.channelId;
return ownClientSnapshotState(_snapshot)?.channelId;
}
@override
Widget build(BuildContext context) {
if (widget.refreshListenable != null) {
return ListenableBuilder(
listenable: widget.refreshListenable!,
builder: (context, _) => _buildScaffold(context),
);
}
return _buildScaffold(context);
}
Widget _buildScaffold(BuildContext context) {
final snapshot = _snapshot;
final messages = _messages;
final currentChannelId = _currentChannelId;
final channelName = snapshotChannelName(widget.snapshot, currentChannelId);
final channelName = snapshotChannelName(snapshot, currentChannelId);
return Scaffold(
appBar: AppBar(
title: Text('Chat — ${widget.snapshot.serverName}'),
title: Text('Chat — ${snapshot.serverName}'),
actions: [
IconButton(
tooltip: 'Close chat',
@@ -334,8 +642,8 @@ class _ChatPageState extends State<ChatPage> {
child: _ChatDetailView(
target: _selectedTarget,
clientName: _selectedClientName,
snapshot: widget.snapshot,
messages: widget.messages,
snapshot: snapshot,
messages: messages,
currentChannelId: currentChannelId,
channelName: channelName,
onTs3ServerLink: widget.onTs3ServerLink,
@@ -353,7 +661,7 @@ class _ChatPageState extends State<ChatPage> {
List<_PrivateChatItem> get _privateChats {
final chats = <BigInt, _PrivateChatItem>{};
for (final message in widget.messages) {
for (final message in _messages) {
final target = message.target;
if (target is! rust.BridgeMessageTarget_Client) continue;
final id = target.field0;
@@ -378,7 +686,7 @@ class _ChatPageState extends State<ChatPage> {
}
String _privateChatName(BigInt id, String fallback) {
for (final client in widget.snapshot.clients) {
for (final client in _snapshot.clients) {
if (client.id == id && client.name.isNotEmpty) return client.name;
}
return fallback.isNotEmpty && fallback != 'You' ? fallback : 'Direct';
@@ -443,7 +751,7 @@ class _ChatSidebar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
width: 148,
width: _chatSidebarTileExtent,
child: Column(
children: [
_ChatSidebarItem(
@@ -513,30 +821,45 @@ class _ChatSidebarItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bg = selected
? theme.colorScheme.primaryContainer
: Colors.transparent;
final fg = selected
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface;
final fg = selected ? Colors.white : theme.colorScheme.onSurface;
return Material(
color: bg,
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: SizedBox(
height: 44,
borderRadius: BorderRadius.circular(8),
child: Ink(
width: _chatSidebarTileExtent,
height: _chatSidebarTileExtent,
decoration: BoxDecoration(
color: selected
? _chatSidebarSelectedTileColor
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18, color: fg),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(color: fg),
SizedBox(
width: 24,
height: 24,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [Icon(icon, size: 18, color: fg)],
),
),
const SizedBox(height: 8),
Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.textTheme.labelSmall?.copyWith(
color: fg,
height: 1.1,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
],
@@ -693,6 +1016,8 @@ class _ChatDetailView extends StatefulWidget {
class _ChatDetailViewState extends State<_ChatDetailView> {
final _textCtl = TextEditingController();
final _scrollCtl = ScrollController();
int _lastRenderedMessageCount = -1;
rust.BridgeMessageTarget? _lastRenderedTarget;
Iterable<ChatEntry> get _filtered {
if (widget.target is rust.BridgeMessageTarget_Channel) {
@@ -758,10 +1083,22 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
}
}
void _scheduleScrollIfNeeded(int messageCount) {
final targetChanged = _lastRenderedTarget != widget.target;
final countChanged = _lastRenderedMessageCount != messageCount;
_lastRenderedTarget = widget.target;
_lastRenderedMessageCount = messageCount;
if (!targetChanged && !countChanged) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _scrollToBottom();
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final msgs = _filtered.toList();
_scheduleScrollIfNeeded(msgs.length);
final placeholder = chatInputPlaceholder(
widget.target,
channelName: widget.channelName,
@@ -882,10 +1219,13 @@ class _MessageBubble extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (entry.isPoke) {
return _PokeHistoryRow(entry: entry);
}
if (entry.isServerActivity) {
return _ServerActivityRow(entry: entry);
}
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
@@ -947,6 +1287,61 @@ class _MessageBubble extends StatelessWidget {
}
}
class _ServerActivityRow extends StatelessWidget {
const _ServerActivityRow({required this.entry});
final ChatEntry entry;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final timestampStyle = theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF8A98A8),
fontStyle: FontStyle.italic,
fontWeight: FontWeight.w400,
letterSpacing: 0.15,
fontFeatures: const [FontFeature.tabularFigures()],
);
final baseStyle = theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF6C7C8F),
fontWeight: FontWeight.w400,
fontSize: 13,
height: 1.45,
letterSpacing: 0.05,
);
final spans = <InlineSpan>[
TextSpan(
text: '<${chatTimeLabel(entry.timestamp)}> ',
style: timestampStyle,
),
];
for (final segment in _activitySegments(entry.message)) {
spans.add(
TextSpan(
text: segment.text,
style: baseStyle?.copyWith(
color: _activityColor(context, segment.tone),
fontWeight: segment.bold ? FontWeight.w700 : FontWeight.w400,
),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 3),
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFFF7F9FC),
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text.rich(TextSpan(children: spans)),
),
),
);
}
}
class _PokeHistoryRow extends StatelessWidget {
const _PokeHistoryRow({required this.entry});