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:
parent
e6f864d409
commit
5721d75461
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue