feat: integrate chat voice and diagnostics client
This commit is contained in:
@@ -3,14 +3,14 @@
|
||||
// `BridgeEvent::VoiceState` stream the bridge publishes from the
|
||||
// core's transmit-mode selector + release-tail timer.
|
||||
|
||||
import 'dart:async' show unawaited;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:haptic_kit/haptic_kit.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import 'ptt_capability_badge.dart';
|
||||
import 'voice_compact.dart';
|
||||
import 'voice_level_meter.dart';
|
||||
import 'voice_platform.dart';
|
||||
import 'voice_status_summary.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
|
||||
@@ -30,27 +30,16 @@ class VoiceBar extends StatelessWidget {
|
||||
required this.pttBackendId,
|
||||
required this.pttBoundInputClass,
|
||||
required this.pttBoundKeyLabel,
|
||||
required this.onToggleMute,
|
||||
required this.onToggleOutputMute,
|
||||
required this.onConfigure,
|
||||
required this.onPttHeldChanged,
|
||||
this.talkPowerBlocked = false,
|
||||
});
|
||||
|
||||
/// True when the session is currently joined to a voice channel.
|
||||
final bool inChannel;
|
||||
|
||||
/// Active transmit mode.
|
||||
final rust.BridgeTransmitMode transmitMode;
|
||||
|
||||
/// Hard-mute clamp state.
|
||||
final bool hardMute;
|
||||
|
||||
/// Speaker (output) mute state. Mirrors the server-broadcast
|
||||
/// `ClientOutputMuted` flag plus the engine's local output
|
||||
/// silencer — toggling this hushes incoming voice immediately
|
||||
/// AND tells the server so other clients see the headphone-off
|
||||
/// icon next to our name.
|
||||
final bool outputMuted;
|
||||
final bool talkPowerBlocked;
|
||||
|
||||
/// Configured release-tail in milliseconds (0..=500). Surfaced as
|
||||
/// a hint underneath the mode badge.
|
||||
@@ -77,12 +66,6 @@ class VoiceBar extends StatelessWidget {
|
||||
/// Platform-neutral key label captured by the binding dialog.
|
||||
final String pttBoundKeyLabel;
|
||||
|
||||
/// Toggle the hard-mute clamp.
|
||||
final VoidCallback onToggleMute;
|
||||
|
||||
/// Toggle speaker (output) mute.
|
||||
final VoidCallback onToggleOutputMute;
|
||||
|
||||
/// Open the voice settings dialog. This is the SINGLE entry point
|
||||
/// for transmit-mode selection, PTT key binding, and release-tail
|
||||
/// configuration. The capability badge below is information-only
|
||||
@@ -98,17 +81,6 @@ class VoiceBar extends StatelessWidget {
|
||||
/// timer handles the trailing tail (SDD-096).
|
||||
final ValueChanged<bool> onPttHeldChanged;
|
||||
|
||||
String _modeLabel(AppL10n l10n) {
|
||||
switch (transmitMode) {
|
||||
case rust.BridgeTransmitMode.ptt:
|
||||
return l10n.voiceModePtt;
|
||||
case rust.BridgeTransmitMode.continuous:
|
||||
return l10n.voiceModeContinuous;
|
||||
case rust.BridgeTransmitMode.voiceActivity:
|
||||
return l10n.voiceModeVoiceActivity;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
@@ -116,344 +88,181 @@ class VoiceBar extends StatelessWidget {
|
||||
final stats = audioStats;
|
||||
final levelActive = stats?.pttActive ?? false;
|
||||
final isPtt = transmitMode == rust.BridgeTransmitMode.ptt;
|
||||
final summary = voiceStatusSummary(
|
||||
l10n: l10n,
|
||||
transmitMode: transmitMode,
|
||||
releaseTailMs: releaseTailMs,
|
||||
pttBoundKeyLabel: pttBoundKeyLabel,
|
||||
isTouchOnly: isTouchOnlyPttHost,
|
||||
inputMuted: hardMute,
|
||||
outputMuted: outputMuted,
|
||||
pttActive: stats?.pttActive ?? false,
|
||||
talkPower: talkPowerBlocked ? 0 : null,
|
||||
neededTalkPower: talkPowerBlocked ? 1 : null,
|
||||
talkPowerGranted: false,
|
||||
);
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Row 1: channel pill + mute toggle. The pill is
|
||||
// wrapped in `Flexible` so very long channel names
|
||||
// truncate with an ellipsis instead of overflowing the
|
||||
// Voice Bar's column width (320 dp in the wide layout)
|
||||
// and pushing the mute icons under the adjacent channel
|
||||
// tree.
|
||||
Row(
|
||||
children: [
|
||||
if (inChannel && channelName.isNotEmpty) ...[
|
||||
Flexible(
|
||||
flex: 100,
|
||||
fit: FlexFit.loose,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.tag,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
channelName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: false,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: summary.talkPowerBlocked
|
||||
? Colors.amber.withValues(alpha: 0.18)
|
||||
: summary.muted
|
||||
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
|
||||
: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: summary.talkPowerBlocked
|
||||
? Colors.amber.shade700
|
||||
: summary.muted
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.outlineVariant,
|
||||
width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Row 1: mode badge
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
transmitMode == rust.BridgeTransmitMode.ptt
|
||||
? Icons.radio_button_checked
|
||||
: Icons.podcasts,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
summary.modeLabel,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.tune, size: 16),
|
||||
label: Text(l10n.voiceSettingsTitle),
|
||||
onPressed: onConfigure,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: l10n.voiceOutputMuteLabel,
|
||||
icon: Icon(outputMuted ? Icons.headset_off : Icons.headset),
|
||||
isSelected: outputMuted,
|
||||
selectedIcon: const Icon(Icons.headset_off),
|
||||
onPressed: onToggleOutputMute,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.voiceHardMuteLabel,
|
||||
icon: Icon(hardMute ? Icons.mic_off : Icons.mic),
|
||||
isSelected: hardMute,
|
||||
selectedIcon: const Icon(Icons.mic_off),
|
||||
onPressed: onToggleMute,
|
||||
),
|
||||
// Status row: talk power / mic / speaker state.
|
||||
if (inChannel) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
summary.talkPowerBlocked
|
||||
? 'Insufficient talk power'
|
||||
: summary.statusText,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: summary.talkPowerBlocked
|
||||
? Colors.amber.shade700
|
||||
: summary.muted
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// Row 2: mode badge
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
transmitMode == rust.BridgeTransmitMode.ptt
|
||||
? Icons.radio_button_checked
|
||||
: Icons.podcasts,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
// Row 3: PTT-only secondary content.
|
||||
//
|
||||
// On hardware-keyboard hosts (Windows / macOS / Linux /
|
||||
// Web) this is a one-line bound-key + release-tail hint
|
||||
// sitting right under the mode badge.
|
||||
//
|
||||
// On touch-only hosts (iOS / iPadOS / Android) the
|
||||
// on-screen Push to Talk button is rendered AT THE
|
||||
// BOTTOM of the Voice Bar (see below) so it sits
|
||||
// closest to the user's thumb when the Voice Bar is
|
||||
// pinned to the bottom of a narrow-layout screen. The
|
||||
// release-tail value is folded into the small print
|
||||
// under the button rather than shown here.
|
||||
if (isPtt && !isTouchOnlyPttHost)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 22, top: 2),
|
||||
child: Text(
|
||||
_modeLabel(l10n),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
'${l10n.voiceModePtt}: '
|
||||
'${pttBoundKeyLabel.isEmpty ? "—" : pttBoundKeyLabel}'
|
||||
' · '
|
||||
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.tune, size: 16),
|
||||
label: Text(l10n.voiceSettingsTitle),
|
||||
onPressed: onConfigure,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Row 3: PTT-only secondary content.
|
||||
//
|
||||
// On hardware-keyboard hosts (Windows / macOS / Linux /
|
||||
// Web) this is a one-line bound-key + release-tail hint
|
||||
// sitting right under the mode badge.
|
||||
//
|
||||
// On touch-only hosts (iOS / iPadOS / Android) the
|
||||
// on-screen Push to Talk button is rendered AT THE
|
||||
// BOTTOM of the Voice Bar (see below) so it sits
|
||||
// closest to the user's thumb when the Voice Bar is
|
||||
// pinned to the bottom of a narrow-layout screen. The
|
||||
// release-tail value is folded into the small print
|
||||
// under the button rather than shown here.
|
||||
if (isPtt && !isTouchOnlyPttHost)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 22, top: 2),
|
||||
child: Text(
|
||||
'${l10n.voiceModePtt}: '
|
||||
'${pttBoundKeyLabel.isEmpty ? "—" : pttBoundKeyLabel}'
|
||||
' · '
|
||||
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// Row 4: level meter
|
||||
_LevelMeter(active: levelActive),
|
||||
const SizedBox(height: 4),
|
||||
if (stats != null)
|
||||
Text(
|
||||
l10n.audioStatsLine(
|
||||
stats.framesSent,
|
||||
stats.framesReceived,
|
||||
stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff,
|
||||
),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// PTT capability badge — only relevant when PTT mode is
|
||||
// active. Hidden for Continuous / Voice Activity since
|
||||
// there's no key binding to surface a capability for.
|
||||
// The badge is information-only; the user reaches the
|
||||
// bind-key flow through the Voice Bar's settings gear
|
||||
// (single configuration entry point — see the comment
|
||||
// on `onConfigure`).
|
||||
//
|
||||
// Still shown on touch-only mobile hosts because iOS P0
|
||||
// acceptance requires an explicit `L0Focused` badge and
|
||||
// explanation that global hotkeys are not available in
|
||||
// the iOS sandbox.
|
||||
if (isPtt)
|
||||
PttCapabilityBadge(
|
||||
level: pttLevel,
|
||||
backendId: pttBackendId,
|
||||
boundInputClass: pttBoundInputClass,
|
||||
),
|
||||
// On touch-only mobile hosts the Push to Talk button is
|
||||
// the LAST element of the Voice Bar so it lands closest
|
||||
// to the user's thumb when the Voice Bar is pinned to
|
||||
// the bottom of a narrow-layout screen. The release-
|
||||
// tail value sits above the button so the user sees
|
||||
// how long their voice continues after they let go.
|
||||
if (isPtt && isTouchOnlyPttHost) ...[
|
||||
const SizedBox(height: 6),
|
||||
// Row 4: level meter
|
||||
VoiceLevelMeter(active: levelActive),
|
||||
const SizedBox(height: 4),
|
||||
Center(
|
||||
child: Text(
|
||||
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
if (stats != null)
|
||||
Text(
|
||||
l10n.audioStatsLine(
|
||||
stats.framesSent,
|
||||
stats.framesReceived,
|
||||
stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff,
|
||||
),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_PttHoldButton(
|
||||
active: levelActive,
|
||||
onHeldChanged: onPttHeldChanged,
|
||||
),
|
||||
],
|
||||
// Leave-voice button intentionally absent: TeamSpeak's
|
||||
// model is "user is always in some channel", not
|
||||
// Discord's join/leave-voice. To stop being heard /
|
||||
// hearing others, mute mic and/or speaker via the
|
||||
// icons at the top of the bar. To physically move,
|
||||
// tap a different channel in the tree below.
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LevelMeter extends StatelessWidget {
|
||||
const _LevelMeter({required this.active});
|
||||
|
||||
final bool active;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
widthFactor: active ? 0.75 : 0.05,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// On-screen push-to-talk button for touch-only mobile platforms
|
||||
/// (iOS / iPadOS / Android). Hardware-keyboard hosts hide this in
|
||||
/// favour of a bound key.
|
||||
///
|
||||
/// Behaviour:
|
||||
/// * `onPanDown` (finger touches the button) → fires
|
||||
/// `onHeldChanged(true)`. The Rust release-tail timer treats
|
||||
/// this as `key_down`.
|
||||
/// * `onPanEnd` / `onPanCancel` (finger lifts or drags off) →
|
||||
/// fires `onHeldChanged(false)` → `key_up` → tail expires →
|
||||
/// mic closes.
|
||||
///
|
||||
/// Using `GestureDetector` rather than `Listener` because we want
|
||||
/// gesture-arena semantics: if the user starts dragging the
|
||||
/// channel-tree underneath, the PTT should release. `onPanCancel`
|
||||
/// fires in that case.
|
||||
///
|
||||
/// The button visually mirrors the `_LevelMeter` state via the
|
||||
/// `active` flag so the user gets feedback that holding actually
|
||||
/// engaged the mic.
|
||||
class _PttHoldButton extends StatefulWidget {
|
||||
const _PttHoldButton({required this.active, required this.onHeldChanged});
|
||||
|
||||
final bool active;
|
||||
final ValueChanged<bool> onHeldChanged;
|
||||
|
||||
@override
|
||||
State<_PttHoldButton> createState() => _PttHoldButtonState();
|
||||
}
|
||||
|
||||
class _PttHoldButtonState extends State<_PttHoldButton> {
|
||||
bool _pressed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(Haptics.prepare().catchError((_) => false));
|
||||
}
|
||||
|
||||
void _setHeld(bool held) {
|
||||
if (_pressed == held) return;
|
||||
setState(() => _pressed = held);
|
||||
widget.onHeldChanged(held);
|
||||
_playPressHaptic(held);
|
||||
}
|
||||
|
||||
void _playPressHaptic(bool held) {
|
||||
final haptic = held
|
||||
? Haptics.impact(HapticImpactStyle.medium)
|
||||
: Haptics.selection();
|
||||
unawaited(haptic.catchError((_) {}));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final activeNow = _pressed || widget.active;
|
||||
final l10n = AppL10n.of(context);
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
liveRegion: true,
|
||||
label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
|
||||
hint: l10n.pttHoldToTalkSemanticsHint,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => _setHeld(true),
|
||||
onTapUp: (_) => _setHeld(false),
|
||||
onTapCancel: () => _setHeld(false),
|
||||
onPanDown: (_) => _setHeld(true),
|
||||
onPanEnd: (_) => _setHeld(false),
|
||||
onPanCancel: () => _setHeld(false),
|
||||
child: ExcludeSemantics(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 80),
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: activeNow
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: activeNow
|
||||
? [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withAlpha(100),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
activeNow ? Icons.mic : Icons.mic_none,
|
||||
color: activeNow
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onPrimaryContainer,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: activeNow
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onPrimaryContainer,
|
||||
const SizedBox(height: 6),
|
||||
// PTT capability badge — only relevant when PTT mode is
|
||||
// active. Hidden for Continuous / Voice Activity since
|
||||
// there's no key binding to surface a capability for.
|
||||
// The badge is information-only; the user reaches the
|
||||
// bind-key flow through the Voice Bar's settings gear
|
||||
// (single configuration entry point — see the comment
|
||||
// on `onConfigure`).
|
||||
//
|
||||
// Still shown on touch-only mobile hosts because iOS P0
|
||||
// acceptance requires an explicit `L0Focused` badge and
|
||||
// explanation that global hotkeys are not available in
|
||||
// the iOS sandbox.
|
||||
if (isPtt)
|
||||
PttCapabilityBadge(
|
||||
level: pttLevel,
|
||||
backendId: pttBackendId,
|
||||
boundInputClass: pttBoundInputClass,
|
||||
),
|
||||
// On touch-only mobile hosts the Push to Talk button is
|
||||
// the LAST element of the Voice Bar so it lands closest
|
||||
// to the user's thumb when the Voice Bar is pinned to
|
||||
// the bottom of a narrow-layout screen. The release-
|
||||
// tail value sits above the button so the user sees
|
||||
// how long their voice continues after they let go.
|
||||
if (isPtt && isTouchOnlyPttHost) ...[
|
||||
const SizedBox(height: 4),
|
||||
Center(
|
||||
child: Text(
|
||||
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
VoicePttButton(
|
||||
active: levelActive,
|
||||
onHeldChanged: onPttHeldChanged,
|
||||
height: 64,
|
||||
borderRadius: 12,
|
||||
iconSize: 24,
|
||||
iconGap: 10,
|
||||
blurRadius: 12,
|
||||
spreadRadius: 0,
|
||||
listenForPan: true,
|
||||
labelLetterSpacing: null,
|
||||
),
|
||||
],
|
||||
// Leave-voice button intentionally absent: TeamSpeak's
|
||||
// model is "user is always in some channel", not
|
||||
// Discord's join/leave-voice. To stop being heard /
|
||||
// hearing others, mute mic and/or speaker via the
|
||||
// icons at the top of the bar. To physically move,
|
||||
// tap a different channel in the tree below.
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user