Two related VoiceBar / scaffold issues on wide windows: 1. Banner placement The 'not production ready' tertiaryContainer banner sat full-width above the body Column. In wide layouts (>=840 dp) where the connected view splits into Voice Bar (320 dp) + channel tree (Expanded), the banner spanned both columns and dwarfed the channel-tree pane. Rework: wrap the body in an outer LayoutBuilder so the placement decision can read bodyConstraints.maxWidth. When wide AND connected AND snapshot != null, render the banner inside the left 320 dp SizedBox above the VoiceBar. In every other state (narrow, idle, connecting) the banner stays pinned full-width at the top. 2. Channel-name pill overflow The Container holding the channel pill had no width constraint and Text(channelName) had no overflow handling. Long channel names made the pill extend past the column's 320 dp; mute icons slid under the adjacent channel tree. Rework: pill wrapped in Flexible(flex: 100, fit: FlexFit.loose); inner Text gets maxLines: 1, overflow: TextOverflow.ellipsis, softWrap: false. Spacer keeps default flex 1; the 100:1 ratio means short names hug their intrinsic width and long names take ~99% of the remaining space then ellipsize. Mute icons stay pinned right. flutter analyze: clean (6 pre-existing Radio.groupValue infos only).
292 lines
10 KiB
Dart
292 lines
10 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. 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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|