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 '../src/rust/api.dart' as rust; import 'bbcode_text.dart'; import 'talk_power_warning.dart'; /// 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.unreadChannelIds, required this.onJoinChannel, required this.onJoinChannelWithPassword, this.enableClientLongPressMenu = false, this.onOpenClientInfo, this.onOpenClientChat, this.onOpenClientPoke, this.onOpenChannelChat, 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; /// Set of channel IDs that have unread chat messages. final Set unreadChannelIds; /// Join an unlocked channel. final ValueChanged onJoinChannel; /// Join a password-protected channel. final ValueChanged onJoinChannelWithPassword; /// True on touch-only mobile hosts where long-press opens client actions. final bool enableClientLongPressMenu; /// Open richer profile details for a non-self client. final ValueChanged? onOpenClientInfo; /// Open a direct chat with a non-self client. final ValueChanged? onOpenClientChat; /// Open a poke composer for a non-self client. final ValueChanged? onOpenClientPoke; /// Open chat for a channel. final ValueChanged? onOpenChannelChat; /// 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; final _scrollController = ScrollController(); final Map _channelExpandedById = {}; final _clientVolumePrefs = _ClientVolumePreferences.instance; bool _welcomeExpanded = false; double _welcomeHeight = 0; final _welcomeKey = GlobalKey(); @override void initState() { super.initState(); _scrollController.addListener(_onScroll); _clientVolumePrefs.addListener(_onClientVolumePrefsChanged); 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); } } void _onClientVolumePrefsChanged() { if (mounted) setState(() {}); } @override void dispose() { _clientVolumePrefs.removeListener(_onClientVolumePrefsChanged); _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 _ChannelContextMenu( channel: channel, onChat: widget.onOpenChannelChat != null ? () => widget.onOpenChannelChat!(channel) : null, child: 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 (widget.unreadChannelIds.contains(channel.id)) ...[ const SizedBox(width: 8), Container( width: 8, height: 8, decoration: BoxDecoration( color: theme.colorScheme.primary, shape: BoxShape.circle, ), ), ], 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 isSelf = client.id == widget.snapshot.ownClientId; final volumePref = isSelf ? const _ClientVolumePreference() : _clientVolumePrefs.preferenceFor(client.id); final canOpenPeerActions = !isSelf && (widget.onOpenClientChat != null || widget.onOpenClientPoke != null); final canOpenClientMenu = widget.onOpenClientInfo != null || canOpenPeerActions || !isSelf; final decoration = status.isSpeaking ? BoxDecoration( color: theme.colorScheme.surfaceContainerHighest.withValues( alpha: 0.62, ), borderRadius: BorderRadius.circular(8), ) : null; final tile = Padding( padding: EdgeInsets.only( left: channelIndent + _userRowStartIndent, right: 8, ), child: AnimatedContainer( duration: const Duration(milliseconds: 120), curve: Curves.easeOut, decoration: decoration, child: ListTile( dense: true, visualDensity: VisualDensity.compact, leading: _ClientAvatarVoiceIndicator( name: client.name, speaking: status.isSpeaking, badgeIcon: status.badgeIcon, badgeColor: status.badgeColor, badgeTooltip: status.badgeTooltip, ), title: Text(client.name, style: nameStyle), trailing: volumePref.isModified ? _ClientVolumeIndicator(preference: volumePref) : null, ), ), ); if (!canOpenClientMenu) return tile; return GestureDetector( behavior: HitTestBehavior.opaque, onSecondaryTapDown: (details) => _showClientMenu(client, details.globalPosition), onLongPressStart: widget.enableClientLongPressMenu ? (details) => _showClientMenu(client, details.globalPosition) : null, child: tile, ); } Future _showClientMenu( rust.BridgeClient client, Offset globalPosition, ) async { final overlay = Overlay.of(context).context.findRenderObject(); if (overlay is! RenderBox) return; final isSelf = client.id == widget.snapshot.ownClientId; final selected = await showMenu<_ClientMenuAction>( context: context, position: RelativeRect.fromRect( Rect.fromLTWH(globalPosition.dx, globalPosition.dy, 0, 0), Offset.zero & overlay.size, ), items: [ if (widget.onOpenClientInfo != null) PopupMenuItem( value: _ClientMenuAction.info, child: ListTile( dense: true, leading: const Icon(Icons.info_outline), title: Text(AppL10n.of(context).clientInfoAction), ), ), if (!isSelf) PopupMenuItem( value: _ClientMenuAction.volume, child: ListTile( dense: true, leading: const Icon(Icons.volume_up), title: Text(AppL10n.of(context).clientVolumeAction), ), ), if (!isSelf && widget.onOpenClientChat != null) PopupMenuItem( value: _ClientMenuAction.directMessage, child: ListTile( dense: true, leading: const Icon(Icons.chat_bubble_outline), title: Text(AppL10n.of(context).chatDirectMessageAction), ), ), if (!isSelf && widget.onOpenClientPoke != null) PopupMenuItem( value: _ClientMenuAction.poke, child: ListTile( dense: true, leading: const Icon(Icons.notifications_active_outlined), title: Text(AppL10n.of(context).chatPokeAction), ), ), ], ); if (!mounted || selected == null) return; switch (selected) { case _ClientMenuAction.info: widget.onOpenClientInfo?.call(client); case _ClientMenuAction.directMessage: widget.onOpenClientChat?.call(client); case _ClientMenuAction.poke: widget.onOpenClientPoke?.call(client); case _ClientMenuAction.volume: await _showVolumeSheet(client); } } Future _showVolumeSheet(rust.BridgeClient client) async { await showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, builder: (sheetContext) => _ClientVolumeSheet(client: client), ); } 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); }); } ({ IconData? badgeIcon, Color? badgeColor, String? badgeTooltip, 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? badgeIcon; final Color? badgeColor; final String? badgeTooltip; if (outputMuted) { badgeIcon = Icons.volume_off; badgeColor = theme.colorScheme.error; badgeTooltip = 'Speaker muted'; } else if (inputMuted) { badgeIcon = Icons.mic_off; badgeColor = theme.colorScheme.error; badgeTooltip = 'Microphone muted'; } else if (talkPowerBlocked) { badgeIcon = Icons.volume_off; badgeColor = theme.colorScheme.error; badgeTooltip = 'Insufficient talk power (${client.talkPower} < $neededTalkPower)'; } else { badgeIcon = null; badgeColor = null; badgeTooltip = null; } return ( badgeIcon: badgeIcon, badgeColor: badgeColor, badgeTooltip: badgeTooltip, isSpeaking: speaking, ); } } enum _ClientMenuAction { info, directMessage, poke, volume } class _ClientVolumePreference { const _ClientVolumePreference({this.volume = 1.0, this.muted = false}); final double volume; final bool muted; bool get isModified => muted || (volume - 1.0).abs() > 0.001; bool get isEffectivelyMuted => muted || volume <= 0.001; } class _ClientVolumePreferences extends ChangeNotifier { _ClientVolumePreferences._(); static final instance = _ClientVolumePreferences._(); final Map _byClientId = {}; _ClientVolumePreference preferenceFor(BigInt clientId) { return _byClientId[clientId] ?? const _ClientVolumePreference(); } void setPreference(BigInt clientId, _ClientVolumePreference preference) { if (preference.isModified) { _byClientId[clientId] = preference; } else { _byClientId.remove(clientId); } notifyListeners(); } } class _ClientAvatarVoiceIndicator extends StatelessWidget { const _ClientAvatarVoiceIndicator({ required this.name, required this.speaking, required this.badgeIcon, required this.badgeColor, required this.badgeTooltip, }); final String name; final bool speaking; final IconData? badgeIcon; final Color? badgeColor; final String? badgeTooltip; @override Widget build(BuildContext context) { final theme = Theme.of(context); final initial = name.trim().isEmpty ? '?' : name.trim()[0].toUpperCase(); return SizedBox( width: 40, height: 40, child: Stack( clipBehavior: Clip.none, children: [ Align( alignment: Alignment.center, child: AnimatedContainer( duration: const Duration(milliseconds: 120), curve: Curves.easeOut, width: 34, height: 34, decoration: BoxDecoration( shape: BoxShape.circle, color: speaking ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest, border: speaking ? Border.all(color: theme.colorScheme.primary, width: 2) : null, boxShadow: speaking ? [ BoxShadow( color: theme.colorScheme.primary.withValues( alpha: 0.20, ), blurRadius: 8, spreadRadius: 1, ), ] : null, ), child: Center( child: Text( initial, style: theme.textTheme.labelLarge?.copyWith( color: speaking ? theme.colorScheme.onPrimaryContainer : theme.colorScheme.onSurfaceVariant, fontWeight: FontWeight.w700, ), ), ), ), ), if (badgeIcon != null && badgeColor != null && badgeTooltip != null) Positioned( right: 0, bottom: 0, child: Tooltip( message: badgeTooltip!, child: Container( width: 18, height: 18, decoration: BoxDecoration( shape: BoxShape.circle, color: theme.colorScheme.surface, border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Icon(badgeIcon, size: 12, color: badgeColor), ), ), ), ], ), ); } } class _ClientVolumeIndicator extends StatelessWidget { const _ClientVolumeIndicator({required this.preference}); final _ClientVolumePreference preference; @override Widget build(BuildContext context) { final theme = Theme.of(context); final percent = (preference.volume * 100).round(); return Row( mainAxisSize: MainAxisSize.min, children: [ if (preference.isEffectivelyMuted) Tooltip( message: 'Locally muted', child: Icon( Icons.volume_off, size: 18, color: theme.colorScheme.error, ), ), if ((preference.volume - 1.0).abs() > 0.001) ...[ if (preference.isEffectivelyMuted) const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(999), ), child: Text( '$percent%', style: theme.textTheme.labelSmall?.copyWith( color: preference.isEffectivelyMuted ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, ), ), ), ], ], ); } } 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 = []; } _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( AppL10n.of(context).serverWelcomeHeading, style: theme.textTheme.labelMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ), AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: expanded ? Padding( key: const ValueKey('welcome-expanded'), padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), child: BbCodeText( welcomeMessage, linkTrust: LinkTrustService.instance, onTs3ServerLink: onTs3ServerLink, ), ) : const SizedBox( key: ValueKey('welcome-collapsed'), width: double.infinity, ), ), ], ), ); } } class _ClientVolumeSheet extends StatefulWidget { const _ClientVolumeSheet({required this.client}); final rust.BridgeClient client; @override State<_ClientVolumeSheet> createState() => _ClientVolumeSheetState(); } class _ClientVolumeSheetState extends State<_ClientVolumeSheet> { static const _maxVolume = 1.5; final _prefs = _ClientVolumePreferences.instance; double _volume = 1.0; bool _muted = false; double _volumeBeforeMute = 1.0; @override void initState() { super.initState(); final preference = _prefs.preferenceFor(widget.client.id); _volume = preference.volume.clamp(0.0, _maxVolume); _muted = preference.muted; _volumeBeforeMute = _volume <= 0.001 ? 1.0 : _volume; } void _applyVolume() { _prefs.setPreference( widget.client.id, _ClientVolumePreference(volume: _volume, muted: _muted), ); rust.setClientVolume( clientId: widget.client.id, volume: _muted ? 0.0 : _volume, ); } @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); final theme = Theme.of(context); final percent = (_volume * 100).round(); final isModified = _volume != 1.0 || _muted; return Padding( padding: EdgeInsets.fromLTRB( 16, 16, 16, 16 + MediaQuery.of(context).viewInsets.bottom, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: Text( l10n.clientVolumeTitle(widget.client.name), style: theme.textTheme.titleMedium, ), ), IconButton( icon: Icon( _muted ? Icons.volume_off : Icons.volume_up, color: _muted ? theme.colorScheme.error : null, ), tooltip: _muted ? l10n.clientVolumeUnmuteAction : l10n.clientVolumeMuteAction, onPressed: () { setState(() { if (!_muted) { _volumeBeforeMute = _volume; _muted = true; } else { _muted = false; _volume = _volumeBeforeMute; } }); _applyVolume(); }, ), ], ), const SizedBox(height: 8), Row( children: [ Expanded( child: Slider( value: _volume, min: 0.0, max: _maxVolume, divisions: 30, label: l10n.clientVolumeLabel(percent), onChanged: _muted ? null : (v) { setState(() => _volume = v); _applyVolume(); }, ), ), SizedBox( width: 52, child: Text( l10n.clientVolumeLabel(percent), style: theme.textTheme.bodyMedium?.copyWith( color: _muted ? theme.colorScheme.error : null, ), textAlign: TextAlign.end, ), ), ], ), if (isModified) Align( alignment: Alignment.centerLeft, child: TextButton.icon( icon: const Icon(Icons.restart_alt, size: 18), label: Text(l10n.clientVolumeResetAction), onPressed: () { setState(() { _volume = 1.0; _muted = false; _volumeBeforeMute = 1.0; }); _applyVolume(); }, ), ), const SizedBox(height: 8), ], ), ); } } /// Context menu for channel tiles. Shows a "Chat" option on right-click or /// long-press. Primary tap passes through to the child for voice join. class _ChannelContextMenu extends StatelessWidget { const _ChannelContextMenu({ required this.channel, this.onChat, required this.child, }); final rust.BridgeChannel channel; final VoidCallback? onChat; final Widget child; @override Widget build(BuildContext context) { if (onChat == null) return child; return GestureDetector( behavior: HitTestBehavior.opaque, onSecondaryTapDown: (details) => _show(context, details.globalPosition), onLongPressStart: (details) => _show(context, details.globalPosition), child: child, ); } void _show(BuildContext context, Offset globalPosition) { final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; final position = RelativeRect.fromLTRB( globalPosition.dx, globalPosition.dy, overlay.size.width - globalPosition.dx, overlay.size.height - globalPosition.dy, ); showMenu( context: context, position: position, items: [ PopupMenuItem( value: 'chat', child: Row( children: [ const Icon(Icons.chat_bubble_outline, size: 18), const SizedBox(width: 12), Text(AppL10n.of(context).chatAction), ], ), ), ], ).then((value) { if (value == 'chat') onChat?.call(); }); } }