1. Voice Bar 'Leave voice' button removed entirely. TeamSpeak users are always in some channel; Discord/Mumble-style leave is the wrong model. To stop being heard / hearing, mute mic / speaker. To physically move, tap a different channel. The voiceLeave bridge call + _onLeaveVoice stay as dead code for now (marked unused) so existing tests/integrations don't break. 2. voice_join now confirms the move actually applied server-side by polling the snapshot for up to 1.5 s and matching our own client's channel against the requested one. If the server rejected the move (no permission, wrong password, channel full), voice_join rolls the selector back to in_channel=false and returns Err so the UI surfaces the failure instead of showing a fake 'joined' state. 3. ServerSnapshot + BridgeSnapshot gain own_client_id so the UI can identify our row without name-matching. find_own_in reads it directly. 4. set_self_muted now also clamps the TransmitModeSelector's hard_mute when input is muted server-side. Without this, the Opus encoder kept producing frames after setInputMuted(true), tsclientlib refused each one with 'Sending audio while muted', and the log grew to 200 MB on the Korean host. 5. tsclientlib WARN spam suppressed via tracing filter (tsclientlib=error). Belt-and-braces on top of fix 4. 6. Log file is now rotated at every launch (not just when >4 MiB). Two generations kept: chanora.log.1 (previous) and chanora.log.2 (the one before). The bug that produced 200 MB files was a chatty subsystem flooding a single session; the per-launch rotate keeps disk use bounded by what one session can produce in its lifetime. Bonus Windows fix (separate from the six but found in the same log): the Raw Input + Hook backends now signal readiness BEFORE blocking on GetMessageW. Previously init_tx.send was called after the loop returned (i.e. on WM_QUIT, which never happens during arming), so the main thread's 2 s readiness probe always timed out and the backend reported L0Focused even when registration succeeded. Both run_raw_input_loop and run_hook_loop now take an init_tx parameter and call report!(true) right after a successful registration, and report!(false) on every early-fail return. cargo check --workspace: clean. cargo test --workspace --lib: 80 passed / 0 failed / 1 ignored. flutter analyze: clean (6 pre-existing Radio.groupValue infos).
277 lines
9.7 KiB
Dart
277 lines
9.7 KiB
Dart
// Voice bar widget (SDD-097). Replaces the legacy `_AudioControls`
|
|
// block that used to live in `main.dart`. Driven by the
|
|
// `BridgeEvent::VoiceState` stream the bridge publishes from the
|
|
// core's transmit-mode selector + release-tail timer.
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../l10n/generated/app_localizations.dart';
|
|
import '../main.dart' show PttCapabilityBadge;
|
|
import '../src/rust/api.dart' as rust;
|
|
|
|
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
|
|
/// toggle, level meter, and a leave-channel affordance.
|
|
class VoiceBar extends StatelessWidget {
|
|
/// Construct a voice bar.
|
|
const VoiceBar({
|
|
super.key,
|
|
required this.inChannel,
|
|
required this.transmitMode,
|
|
required this.hardMute,
|
|
required this.outputMuted,
|
|
required this.releaseTailMs,
|
|
required this.channelName,
|
|
required this.audioStats,
|
|
required this.pttLevel,
|
|
required this.pttBackendId,
|
|
required this.pttBoundInputClass,
|
|
required this.pttBoundKeyLabel,
|
|
required this.onToggleMute,
|
|
required this.onToggleOutputMute,
|
|
required this.onConfigure,
|
|
});
|
|
|
|
/// 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;
|
|
|
|
/// Configured release-tail in milliseconds (0..=500). Surfaced as
|
|
/// a hint underneath the mode badge.
|
|
final int releaseTailMs;
|
|
|
|
/// Channel display name to show in the channel-name pill. Empty
|
|
/// string suppresses the pill (typically when `!inChannel`).
|
|
final String channelName;
|
|
|
|
/// Audio statistics (TX/RX frames + ptt_active) to drive the
|
|
/// level meter. Pass `null` to render an idle meter.
|
|
final rust.BridgeAudioStats? audioStats;
|
|
|
|
/// PTT capability badge inputs — passed through to
|
|
/// [`PttCapabilityBadge`].
|
|
final String pttLevel;
|
|
|
|
/// Backend id, e.g. `focused`, `windows-raw-input`.
|
|
final String pttBackendId;
|
|
|
|
/// Coarse bound input class.
|
|
final String pttBoundInputClass;
|
|
|
|
/// 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
|
|
/// and intentionally does NOT have its own configure affordance.
|
|
final VoidCallback onConfigure;
|
|
|
|
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} (${l10n.voiceModeComingSoon})';
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
final stats = audioStats;
|
|
final levelActive = stats?.pttActive ?? false;
|
|
final isPtt = transmitMode == rust.BridgeTransmitMode.ptt;
|
|
|
|
return Card(
|
|
margin: EdgeInsets.zero,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Row 1: channel pill + mute toggle
|
|
Row(
|
|
children: [
|
|
if (inChannel && channelName.isNotEmpty) ...[
|
|
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),
|
|
Text(
|
|
channelName,
|
|
style: TextStyle(
|
|
color: theme.colorScheme.onPrimaryContainer,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
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(
|
|
child: Text(
|
|
_modeLabel(l10n),
|
|
style: theme.textTheme.bodyMedium?.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 line — bound key + release
|
|
// tail. Hidden entirely for Continuous / Voice Activity
|
|
// so the bar stays focused on what's actually in use.
|
|
if (isPtt)
|
|
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`).
|
|
if (isPtt)
|
|
PttCapabilityBadge(
|
|
level: pttLevel,
|
|
backendId: pttBackendId,
|
|
boundInputClass: pttBoundInputClass,
|
|
),
|
|
// 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),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|