Files
chanora/apps/chanora_flutter/lib/widgets/voice_bar.dart
T

449 lines
16 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 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import '../main.dart' show PttCapabilityBadge;
import '../src/rust/api.dart' as rust;
/// True when the host is a mobile platform without a hardware
/// keyboard the user would bind a PTT key on. iOS / iPadOS /
/// Android fall here. macOS / Linux / Windows / Web fall on the
/// hardware-key path.
bool get _isTouchOnlyPttHost {
if (kIsWeb) return false;
return Platform.isIOS || Platform.isAndroid;
}
/// 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,
required this.onPttHeldChanged,
});
/// 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;
/// Drive the press/release edges of the on-screen PTT button on
/// touch-only mobile platforms (iOS / iPadOS / Android). On
/// desktop platforms this callback is wired but never invoked
/// because the on-screen button is only rendered on mobile.
/// The callee should map `true` to `setPtt(active: true)` and
/// `false` to `setPtt(active: false)`; the Rust release-tail
/// 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} (${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 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: 4),
Center(
child: Text(
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
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;
void _setHeld(bool held) {
if (_pressed == held) return;
setState(() => _pressed = held);
widget.onHeldChanged(held);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final activeNow = _pressed || widget.active;
final l10n = AppL10n.of(context);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => _setHeld(true),
onTapUp: (_) => _setHeld(false),
onTapCancel: () => _setHeld(false),
onPanDown: (_) => _setHeld(true),
onPanEnd: (_) => _setHeld(false),
onPanCancel: () => _setHeld(false),
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,
),
),
],
),
),
),
);
}
}