Files
chanora/apps/chanora_flutter/lib/widgets/voice_bar.dart
T
EdisonJwa 25b5bb3a6d fix(ui): wide-mode banner placement + channel-pill overflow
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).
2026-05-16 12:15:13 +08:00

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),
),
),
),
);
}
}