feat: integrate chat voice and diagnostics client

This commit is contained in:
Edison Jwa
2026-05-23 06:51:55 +09:00
parent 7e28791ec2
commit 7d5d8c2c90
93 changed files with 10273 additions and 3347 deletions
+163 -354
View File
@@ -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.
],
),
),
),