import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../l10n/generated/app_localizations.dart'; import '../services/channel_spacer.dart'; import '../services/link_trust_service.dart'; import '../services/snapshot_state_mapper.dart'; import '../services/ts3_server_link.dart'; import '../services/ui_preferences_service.dart'; import '../src/rust/api.dart' as rust; import 'bbcode_text.dart'; import 'talk_power_warning.dart'; typedef ClientPlaybackPreferenceChanged = Future Function( rust.BridgeClient client, ClientPlaybackPreference preference, ); /// Connected-server snapshot with welcome text, channels, and clients. class SnapshotView extends StatefulWidget { /// Construct a snapshot view. const SnapshotView({ super.key, required this.snapshot, required this.audioStats, required this.currentVoiceChannelId, required this.pendingVoiceChannelId, required this.localInputMuted, required this.localOutputMuted, required this.hasJoinPending, required this.canJoinVoiceChannel, required this.onJoinChannel, required this.onJoinChannelWithPassword, this.clientPlaybackPreferences = const {}, this.onClientPlaybackPreferenceChanged, this.onTs3ServerLink, }); /// Current bridge snapshot. final rust.BridgeSnapshot snapshot; /// Latest audio stats, used for local speaking state. final rust.BridgeAudioStats? audioStats; /// Current voice channel id. final BigInt? currentVoiceChannelId; /// Pending join target, if any. final BigInt? pendingVoiceChannelId; /// Local input mute state. final bool localInputMuted; /// Local output mute state. final bool localOutputMuted; /// True while a channel join is in flight. final bool hasJoinPending; /// True when the local client may join voice channels. final bool canJoinVoiceChannel; /// Join an unlocked channel. final ValueChanged onJoinChannel; /// Join a password-protected channel. final ValueChanged onJoinChannelWithPassword; /// Persisted per-client playback preferences keyed by TeamSpeak UID. final Map clientPlaybackPreferences; /// Apply an updated per-client playback preference. final ClientPlaybackPreferenceChanged? onClientPlaybackPreferenceChanged; /// Handle TeamSpeak server links embedded in server-provided text. final Ts3ServerLinkHandler? onTs3ServerLink; @override State createState() => _SnapshotViewState(); } class _SnapshotViewState extends State { static const _indentPerLevel = 12.0; static const _expandColumnWidth = 28.0; static const _channelIconColumnWidth = 24.0; static const _channelTextGap = 8.0; static const _userRowStartIndent = 32.0; static const _clientPlaybackVolumePresets = [0.25, 0.5, 1.0, 2.0, 4.0]; final _scrollController = ScrollController(); final Map _channelExpandedById = {}; bool _welcomeExpanded = true; double _welcomeHeight = 0; final _welcomeKey = GlobalKey(); @override void initState() { super.initState(); _scrollController.addListener(_onScroll); WidgetsBinding.instance.addPostFrameCallback((_) { final ctx = _welcomeKey.currentContext; if (ctx != null) { final box = ctx.findRenderObject() as RenderBox?; if (box != null && mounted) { setState(() => _welcomeHeight = box.size.height); } } }); } void _onScroll() { if (_welcomeExpanded && _scrollController.offset > _welcomeHeight) { setState(() => _welcomeExpanded = false); } } @override void dispose() { _scrollController.removeListener(_onScroll); _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); final theme = Theme.of(context); final tree = _buildChannelTree(widget.snapshot.channels); final clientsByChannel = >{}; for (final c in widget.snapshot.clients) { if (!c.isServerQuery) { clientsByChannel.putIfAbsent(c.channel, () => []).add(c); } } return ListView( controller: _scrollController, children: [ Text( l10n.countChannelsAndClients( widget.snapshot.channels.length, widget.snapshot.clients.length, ), style: theme.textTheme.bodyMedium, ), if (widget.snapshot.welcomeMessage.isNotEmpty) ...[ const SizedBox(height: 8), _WelcomeMessageTile( key: _welcomeKey, welcomeMessage: widget.snapshot.welcomeMessage, expanded: _welcomeExpanded, onToggle: () => setState(() => _welcomeExpanded = !_welcomeExpanded), onTs3ServerLink: widget.onTs3ServerLink, ), ], const Divider(height: 24), for (final node in tree.roots) ..._channelTreeRows(theme, node, clientsByChannel, 0), ], ); } List _channelTreeRows( ThemeData theme, _ChannelTreeNode node, Map> clientsByChannel, int depth, ) { final channel = node.channel; final clients = clientsByChannel[channel.id] ?? const []; final hasVisibleChildren = clients.isNotEmpty || node.children.isNotEmpty; final expanded = _isChannelExpanded(channel.id); final channelIndent = (depth.clamp(0, 8)) * _indentPerLevel; return [ _channelTile( theme, channel, channelIndent: channelIndent, hasVisibleChildren: hasVisibleChildren, expanded: expanded, onToggleExpanded: hasVisibleChildren ? () => _toggleChannelExpanded(channel.id) : null, ), if (expanded) ...[ for (final client in clients) _clientTile(theme, client, channelIndent), for (final child in node.children) ..._channelTreeRows(theme, child, clientsByChannel, depth + 1), ], ]; } Widget _channelTile( ThemeData theme, rust.BridgeChannel channel, { required double channelIndent, required bool hasVisibleChildren, required bool expanded, required VoidCallback? onToggleExpanded, }) { final spacer = parseSpacerChannelName(channel.name); final onTap = widget.hasJoinPending || !widget.canJoinVoiceChannel || channel.id == widget.currentVoiceChannelId ? null : () => channel.hasPassword ? widget.onJoinChannelWithPassword(channel) : widget.onJoinChannel(channel); if (spacer.isSpacer) { return InkWell( onTap: onTap, child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 40), child: Row( children: [ SizedBox(width: channelIndent), _expandButton( theme, hasVisibleChildren: hasVisibleChildren, expanded: expanded, onPressed: onToggleExpanded, ), const SizedBox(width: _channelTextGap), Expanded(child: _SpacerChannelContent(spacer: spacer)), ], ), ), ); } return InkWell( onTap: onTap, child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 40), child: Row( children: [ SizedBox(width: channelIndent), _expandButton( theme, hasVisibleChildren: hasVisibleChildren, expanded: expanded, onPressed: onToggleExpanded, ), SizedBox( width: _channelIconColumnWidth, child: Align( alignment: Alignment.centerLeft, child: Icon( Icons.tag, color: theme.colorScheme.onSurfaceVariant, ), ), ), const SizedBox(width: _channelTextGap), Expanded( child: Text( channel.name, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), if (channel.hasPassword) ...[ const SizedBox(width: 8), Icon( Icons.lock_outline, color: theme.colorScheme.onSurfaceVariant, ), ], ], ), ), ); } Widget _clientTile( ThemeData theme, rust.BridgeClient client, double channelIndent, ) { final status = _clientVoiceStatusIcon(theme, client); final nameStyle = client.isServerQuery ? TextStyle(color: theme.colorScheme.onSurfaceVariant) : status.isSpeaking ? TextStyle( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, ) : null; final decoration = status.isSpeaking ? BoxDecoration( color: theme.colorScheme.primaryContainer.withValues(alpha: 0.45), borderRadius: BorderRadius.circular(8), border: Border.all( color: theme.colorScheme.primary.withValues(alpha: 0.55), width: 1.2, ), boxShadow: [ BoxShadow( color: theme.colorScheme.primary.withValues(alpha: 0.14), blurRadius: 8, spreadRadius: 1, ), ], ) : null; final canAdjustPlayback = _canAdjustClientPlayback(client); Offset? secondaryTapPosition; return Padding( padding: EdgeInsets.only( left: channelIndent + _userRowStartIndent, right: 8, ), child: GestureDetector( behavior: HitTestBehavior.opaque, onLongPress: canAdjustPlayback ? () => unawaited(_showClientPlaybackMenu(client, withHaptic: true)) : null, onSecondaryTapDown: canAdjustPlayback ? (details) => secondaryTapPosition = details.globalPosition : null, onSecondaryTap: canAdjustPlayback ? () => unawaited( _showClientPlaybackMenu(client, anchor: secondaryTapPosition), ) : null, child: AnimatedContainer( duration: const Duration(milliseconds: 120), curve: Curves.easeOut, decoration: decoration, child: ListTile( dense: true, visualDensity: VisualDensity.compact, leading: status.icon, title: Text(client.name, style: nameStyle), ), ), ), ); } Widget _expandButton( ThemeData theme, { required bool hasVisibleChildren, required bool expanded, required VoidCallback? onPressed, }) { if (!hasVisibleChildren) { return const SizedBox( width: _expandColumnWidth, height: _expandColumnWidth, ); } return Semantics( button: true, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onPressed, child: SizedBox( width: _expandColumnWidth, height: _expandColumnWidth, child: Icon( expanded ? Icons.expand_more : Icons.chevron_right, color: theme.colorScheme.onSurfaceVariant, ), ), ), ); } bool _isChannelExpanded(BigInt channelId) { return _channelExpandedById[channelId] ?? true; } void _toggleChannelExpanded(BigInt channelId) { setState(() { _channelExpandedById[channelId] = !_isChannelExpanded(channelId); }); } bool _canAdjustClientPlayback(rust.BridgeClient client) { return widget.onClientPlaybackPreferenceChanged != null && client.id != widget.snapshot.ownClientId && !client.isServerQuery && client.uid.isNotEmpty; } ClientPlaybackPreference _clientPlaybackPreference(rust.BridgeClient client) { return widget.clientPlaybackPreferences[client.uid] ?? const ClientPlaybackPreference(); } Future _showClientPlaybackMenu( rust.BridgeClient client, { Offset? anchor, bool withHaptic = false, }) async { if (!_canAdjustClientPlayback(client)) return; final onChanged = widget.onClientPlaybackPreferenceChanged; if (onChanged == null) return; if (withHaptic) { unawaited(HapticFeedback.selectionClick().catchError((_) {})); } if (!mounted) return; final preference = _clientPlaybackPreference(client); final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; final position = anchor ?? overlay.size.center(Offset.zero); final selected = await showMenu<_ClientPlaybackMenuAction>( context: context, position: RelativeRect.fromLTRB( position.dx, position.dy, overlay.size.width - position.dx, overlay.size.height - position.dy, ), items: [ PopupMenuItem<_ClientPlaybackMenuAction>( value: const _ToggleMuteClientPlaybackMenuAction(), child: ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: Icon( preference.muted ? Icons.volume_off : Icons.volume_up, ), title: Text(preference.muted ? 'Unmute playback' : 'Mute playback'), subtitle: Text(client.name), ), ), const PopupMenuDivider(), ..._clientPlaybackVolumePresets.map( (preset) => CheckedPopupMenuItem<_ClientPlaybackMenuAction>( value: _VolumeClientPlaybackMenuAction(preset), checked: !preference.muted && (preference.volume - preset).abs() < 0.001, child: Text('Volume ${(preset * 100).round()}%'), ), ), ], ); if (selected == null || !mounted) return; final next = switch (selected) { _ToggleMuteClientPlaybackMenuAction() => preference.copyWith( muted: !preference.muted, ), _VolumeClientPlaybackMenuAction(:final volume) => preference.copyWith( volume: volume, muted: false, ), }; await onChanged(client, next); } ({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon( ThemeData theme, rust.BridgeClient client, ) { final isSelf = client.id == widget.snapshot.ownClientId; final inCurrentChannel = client.channel == widget.currentVoiceChannelId; final outputMuted = isSelf ? widget.localOutputMuted : client.outputMuted; final inputMuted = isSelf ? widget.localInputMuted : client.inputMuted; final neededTalkPower = snapshotNeededTalkPower( widget.snapshot, client.channel, ); final talkPowerBlocked = isSelf && inCurrentChannel && isTalkPowerBlocked( talkPower: client.talkPower, neededTalkPower: neededTalkPower, talkPowerGranted: client.talkPowerGranted, ); final rawSpeaking = isSelf ? (widget.audioStats?.pttActive ?? false) : client.isSpeaking; final transmitAllowed = !outputMuted && !inputMuted && (!isSelf || (inCurrentChannel && !talkPowerBlocked)); final speaking = rawSpeaking && transmitAllowed; final IconData icon; final Color color; final String tooltip; if (outputMuted) { icon = Icons.volume_off; color = theme.colorScheme.error; tooltip = 'Speaker muted'; } else if (inputMuted) { icon = Icons.mic_off; color = theme.colorScheme.error; tooltip = 'Microphone muted'; } else if (talkPowerBlocked) { icon = Icons.volume_off; color = theme.colorScheme.error; tooltip = 'Insufficient talk power (${client.talkPower} < $neededTalkPower)'; } else if (speaking) { icon = isSelf ? Icons.mic : Icons.volume_up; color = theme.colorScheme.primary; tooltip = 'Speaking'; } else if (inCurrentChannel) { icon = isSelf ? Icons.mic_none : Icons.volume_up_outlined; color = theme.colorScheme.onSurfaceVariant; tooltip = 'Not speaking'; } else { icon = Icons.person_outline; color = theme.colorScheme.onSurfaceVariant; tooltip = 'Outside current channel'; } return ( icon: Tooltip( message: tooltip, child: Icon(icon, color: color), ), isSpeaking: speaking, ); } } class _SpacerChannelContent extends StatelessWidget { const _SpacerChannelContent({required this.spacer}); final SpacerChannelNameParseResult spacer; @override Widget build(BuildContext context) { final theme = Theme.of(context); final color = theme.colorScheme.onSurfaceVariant; if (spacer.isBlankSpacer) { return const SizedBox(height: 20); } if (spacer.specialType != null) { return SizedBox( height: 22, child: CustomPaint( painter: _SpacerLinePainter( color: color.withValues(alpha: 0.72), type: spacer.specialType!, ), ), ); } if (spacer.isRepeating) { return LayoutBuilder( builder: (context, constraints) { final pattern = spacer.text.isEmpty ? ' ' : spacer.text; final estimatedColumns = (constraints.maxWidth / 8).ceil().clamp( 1, 256, ); return Text( channelSpacerLabel( formatSpacerChannelName( SpacerChannelNameFormatOptions( alignment: spacer.alignment, isRepeating: true, uniqueSuffix: spacer.uniqueSuffix, text: pattern, ), ), repeatColumns: estimatedColumns, ), maxLines: 1, overflow: TextOverflow.clip, softWrap: false, style: theme.textTheme.bodyMedium?.copyWith(color: color), ); }, ); } return Text( spacer.text, textAlign: switch (spacer.alignment) { SpacerAlignment.left => TextAlign.left, SpacerAlignment.right => TextAlign.right, SpacerAlignment.center => TextAlign.center, null => TextAlign.center, }, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( color: color, fontWeight: FontWeight.w600, ), ); } } class _SpacerLinePainter extends CustomPainter { const _SpacerLinePainter({required this.color, required this.type}); final Color color; final SpacerSpecialType type; @override void paint(Canvas canvas, Size size) { final y = size.height / 2; final paint = Paint() ..color = color ..strokeCap = StrokeCap.square ..strokeWidth = 1.4; switch (type) { case SpacerSpecialType.solidLine: canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); case SpacerSpecialType.dashLine: _drawPattern(canvas, size.width, y, paint, const [8, 5]); case SpacerSpecialType.dotLine: final dotPaint = Paint()..color = color; for (var x = 1.5; x < size.width; x += 7) { canvas.drawCircle(Offset(x, y), 1.5, dotPaint); } case SpacerSpecialType.dashDotLine: _drawPattern(canvas, size.width, y, paint, const [10, 4, 2, 4]); case SpacerSpecialType.dashDotDotLine: _drawPattern(canvas, size.width, y, paint, const [10, 4, 2, 4, 2, 4]); } } void _drawPattern( Canvas canvas, double width, double y, Paint paint, List pattern, ) { var x = 0.0; var index = 0; while (x < width) { final length = pattern[index % pattern.length]; if (index.isEven) { final end = x + length > width ? width : x + length; canvas.drawLine(Offset(x, y), Offset(end, y), paint); } x += length; index += 1; } } @override bool shouldRepaint(covariant _SpacerLinePainter oldDelegate) { return oldDelegate.color != color || oldDelegate.type != type; } } class _ChannelTree { const _ChannelTree({required this.roots}); final List<_ChannelTreeNode> roots; } class _ChannelTreeNode { _ChannelTreeNode(this.channel); final rust.BridgeChannel channel; final List<_ChannelTreeNode> children = []; } sealed class _ClientPlaybackMenuAction { const _ClientPlaybackMenuAction(); } class _ToggleMuteClientPlaybackMenuAction extends _ClientPlaybackMenuAction { const _ToggleMuteClientPlaybackMenuAction(); } class _VolumeClientPlaybackMenuAction extends _ClientPlaybackMenuAction { const _VolumeClientPlaybackMenuAction(this.volume); final double volume; } _ChannelTree _buildChannelTree(List channels) { final byParent = >{}; final knownIds = {for (final channel in channels) channel.id}; for (final channel in channels) { final parent = knownIds.contains(channel.parent) ? channel.parent : BigInt.zero; byParent.putIfAbsent(parent, () => []).add(channel); } _ChannelTreeNode buildNode(rust.BridgeChannel channel) { final node = _ChannelTreeNode(channel); for (final child in byParent[channel.id] ?? const []) { node.children.add(buildNode(child)); } return node; } return _ChannelTree( roots: [ for (final channel in byParent[BigInt.zero] ?? const []) buildNode(channel), ], ); } class _WelcomeMessageTile extends StatelessWidget { const _WelcomeMessageTile({ super.key, required this.welcomeMessage, required this.expanded, required this.onToggle, this.onTs3ServerLink, }); final String welcomeMessage; final bool expanded; final VoidCallback onToggle; final Ts3ServerLinkHandler? onTs3ServerLink; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(6), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ InkWell( onTap: () { HapticFeedback.selectionClick(); onToggle(); }, borderRadius: const BorderRadius.vertical(top: Radius.circular(6)), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Row( children: [ Icon( expanded ? Icons.expand_less : Icons.expand_more, size: 18, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: 4), Text( 'Server welcome message', style: theme.textTheme.labelMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ), AnimatedCrossFade( firstChild: Padding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), child: BbCodeText( welcomeMessage, linkTrust: LinkTrustService.instance, onTs3ServerLink: onTs3ServerLink, ), ), secondChild: const SizedBox(width: double.infinity), crossFadeState: expanded ? CrossFadeState.showFirst : CrossFadeState.showSecond, duration: const Duration(milliseconds: 200), ), ], ), ); } }