feat(it0_app): add PTT mode to agent call page

- Default to PTT (push-to-talk) on call connect: mic muted until user holds button
- Toggle switch between PTT and free voice mode in active call controls
- PTT button: press-and-hold unmutes mic, release mutes again
- Voice message bubble (waveform + duration) appears after each PTT send
- Mute button hidden in PTT mode (mic controlled by PTT button)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-06 06:49:53 -08:00
parent e6f864d409
commit 5721d75461
1 changed files with 204 additions and 9 deletions

View File

@ -72,6 +72,12 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
final List<_CallMessage> _messages = [];
final ScrollController _scrollController = ScrollController();
// PTT (Push-to-Talk) mode
bool _isPttMode = true;
bool _pttRecording = false;
final Stopwatch _pttStopwatch = Stopwatch();
Timer? _pttTimer;
@override
void initState() {
super.initState();
@ -207,8 +213,8 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
),
);
// 5. Enable microphone (publishes local audio track)
await _room!.localParticipant?.setMicrophoneEnabled(true);
// 5. Enable microphone muted by default in PTT mode
await _room!.localParticipant?.setMicrophoneEnabled(!_isPttMode);
// 6. Start duration timer
_stopwatch.start();
@ -304,6 +310,66 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
setState(() {});
}
// ---------------------------------------------------------------------------
// PTT (Push-to-Talk)
// ---------------------------------------------------------------------------
void _startPtt() {
if (!_isPttMode || _phase != _CallPhase.active || _pttRecording) return;
_room?.localParticipant?.setMicrophoneEnabled(true);
_pttStopwatch
..reset()
..start();
_pttTimer = Timer.periodic(const Duration(milliseconds: 50), (_) {
if (mounted) setState(() {});
});
setState(() => _pttRecording = true);
}
void _stopPtt() {
if (!_pttRecording) return;
_room?.localParticipant?.setMicrophoneEnabled(false);
_pttStopwatch.stop();
_pttTimer?.cancel();
_pttTimer = null;
final elapsed = _pttStopwatch.elapsed;
if (elapsed.inMilliseconds >= 300) {
_messages.add(_CallMessage(
text: '',
isUser: true,
timestamp: DateTime.now(),
isVoice: true,
voiceDurationMs: elapsed.inMilliseconds,
));
_scrollToBottom();
}
setState(() => _pttRecording = false);
}
void _togglePttMode() {
if (_pttRecording) _stopPtt();
final newPtt = !_isPttMode;
setState(() => _isPttMode = newPtt);
if (_phase == _CallPhase.active) {
_room?.localParticipant?.setMicrophoneEnabled(!newPtt);
}
}
String _formatPttDuration() {
final ms = _pttStopwatch.elapsedMilliseconds;
final secs = ms ~/ 1000;
final tenths = (ms % 1000) ~/ 100;
return '$secs.$tenths"';
}
String _formatVoiceDuration(int ms) {
final secs = ms ~/ 1000;
if (secs < 60) return '${secs}s';
final mins = secs ~/ 60;
final s = secs % 60;
return '$mins:${s.toString().padLeft(2, '0')}';
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
@ -658,6 +724,10 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
),
if (_phase == _CallPhase.active)
_agentState == 'thinking' ? _buildThinkingDots() : _buildWaveform(),
if (_phase == _CallPhase.active && _isPttMode) ...[
const SizedBox(height: 28),
_buildPttButton(),
],
const Spacer(flex: 3),
_buildControls(),
const SizedBox(height: 48),
@ -774,6 +844,7 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
}
Widget _buildMessageBubble(_CallMessage msg) {
if (msg.isVoice) return _buildVoiceMessageBubble(msg);
final isUser = msg.isUser;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
@ -1168,6 +1239,115 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
);
}
/// Large press-and-hold PTT button shown in call mode.
Widget _buildPttButton() {
return Listener(
onPointerDown: (_) => _startPtt(),
onPointerUp: (_) => _stopPtt(),
onPointerCancel: (_) => _stopPtt(),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: _pttRecording ? 100 : 84,
height: _pttRecording ? 100 : 84,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _pttRecording
? AppColors.error.withOpacity(0.15)
: AppColors.primary.withOpacity(0.12),
border: Border.all(
color: _pttRecording ? AppColors.error : AppColors.primary,
width: _pttRecording ? 3 : 2,
),
boxShadow: _pttRecording
? [
BoxShadow(
color: AppColors.error.withOpacity(0.35),
blurRadius: 24,
spreadRadius: 4,
)
]
: [],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_pttRecording ? Icons.mic : Icons.mic_none,
size: _pttRecording ? 36 : 30,
color: _pttRecording ? AppColors.error : AppColors.primary,
),
const SizedBox(height: 2),
Text(
_pttRecording ? _formatPttDuration() : '按住说话',
style: TextStyle(
fontSize: _pttRecording ? 12 : 11,
color: _pttRecording ? AppColors.error : AppColors.textSecondary,
fontWeight: _pttRecording ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
);
}
/// Voice message bubble: decorative waveform + duration.
Widget _buildVoiceMessageBubble(_CallMessage msg) {
final rand = Random(msg.voiceDurationMs ^ msg.timestamp.millisecondsSinceEpoch);
final bars = List.generate(18, (_) => 0.15 + rand.nextDouble() * 0.85);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.15),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(4),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.mic, size: 16, color: AppColors.primary),
const SizedBox(width: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: bars
.map((h) => Container(
width: 3,
height: 6 + h * 18,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.65),
borderRadius: BorderRadius.circular(1.5),
),
))
.toList(),
),
const SizedBox(width: 6),
Text(
_formatVoiceDuration(msg.voiceDurationMs),
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
),
),
],
),
),
const SizedBox(width: 8),
],
),
);
}
Widget _buildControls() {
switch (_phase) {
case _CallPhase.ringing:
@ -1197,6 +1377,13 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_CircleButton(
icon: _isPttMode ? Icons.touch_app : Icons.wifi_tethering,
label: _isPttMode ? 'PTT' : '自由',
isActive: _isPttMode,
onTap: _togglePttMode,
),
const SizedBox(width: 16),
_CircleButton(
icon: _isInputExpanded ? Icons.keyboard_hide : Icons.keyboard,
label: _isInputExpanded ? '收起' : '键盘',
@ -1204,13 +1391,15 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
onTap: () => setState(() => _isInputExpanded = !_isInputExpanded),
),
const SizedBox(width: 16),
_CircleButton(
icon: _isMuted ? Icons.mic_off : Icons.mic,
label: _isMuted ? '取消静音' : '静音',
isActive: _isMuted,
onTap: _toggleMute,
),
const SizedBox(width: 16),
if (!_isPttMode) ...[
_CircleButton(
icon: _isMuted ? Icons.mic_off : Icons.mic,
label: _isMuted ? '取消静音' : '静音',
isActive: _isMuted,
onTap: _toggleMute,
),
const SizedBox(width: 16),
],
FloatingActionButton(
heroTag: 'end',
backgroundColor: AppColors.error,
@ -1240,6 +1429,8 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
_scrollController.dispose();
_durationTimer?.cancel();
_waveTimer?.cancel();
_pttTimer?.cancel();
_pttStopwatch.stop();
_waveController.dispose();
_thinkingController.dispose();
_stopwatch.stop();
@ -1259,12 +1450,16 @@ class _CallMessage {
final bool isUser;
final DateTime timestamp;
final List<ChatAttachment>? attachments;
final bool isVoice;
final int voiceDurationMs;
_CallMessage({
required this.text,
required this.isUser,
required this.timestamp,
this.attachments,
this.isVoice = false,
this.voiceDurationMs = 0,
});
}