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 List<_CallMessage> _messages = [];
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
// PTT (Push-to-Talk) mode
|
||||||
|
bool _isPttMode = true;
|
||||||
|
bool _pttRecording = false;
|
||||||
|
final Stopwatch _pttStopwatch = Stopwatch();
|
||||||
|
Timer? _pttTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -207,8 +213,8 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5. Enable microphone (publishes local audio track)
|
// 5. Enable microphone — muted by default in PTT mode
|
||||||
await _room!.localParticipant?.setMicrophoneEnabled(true);
|
await _room!.localParticipant?.setMicrophoneEnabled(!_isPttMode);
|
||||||
|
|
||||||
// 6. Start duration timer
|
// 6. Start duration timer
|
||||||
_stopwatch.start();
|
_stopwatch.start();
|
||||||
|
|
@ -304,6 +310,66 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
setState(() {});
|
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() {
|
void _scrollToBottom() {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (_scrollController.hasClients) {
|
if (_scrollController.hasClients) {
|
||||||
|
|
@ -658,6 +724,10 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
),
|
),
|
||||||
if (_phase == _CallPhase.active)
|
if (_phase == _CallPhase.active)
|
||||||
_agentState == 'thinking' ? _buildThinkingDots() : _buildWaveform(),
|
_agentState == 'thinking' ? _buildThinkingDots() : _buildWaveform(),
|
||||||
|
if (_phase == _CallPhase.active && _isPttMode) ...[
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
_buildPttButton(),
|
||||||
|
],
|
||||||
const Spacer(flex: 3),
|
const Spacer(flex: 3),
|
||||||
_buildControls(),
|
_buildControls(),
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
|
|
@ -774,6 +844,7 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMessageBubble(_CallMessage msg) {
|
Widget _buildMessageBubble(_CallMessage msg) {
|
||||||
|
if (msg.isVoice) return _buildVoiceMessageBubble(msg);
|
||||||
final isUser = msg.isUser;
|
final isUser = msg.isUser;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
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() {
|
Widget _buildControls() {
|
||||||
switch (_phase) {
|
switch (_phase) {
|
||||||
case _CallPhase.ringing:
|
case _CallPhase.ringing:
|
||||||
|
|
@ -1197,6 +1377,13 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
_CircleButton(
|
||||||
|
icon: _isPttMode ? Icons.touch_app : Icons.wifi_tethering,
|
||||||
|
label: _isPttMode ? 'PTT' : '自由',
|
||||||
|
isActive: _isPttMode,
|
||||||
|
onTap: _togglePttMode,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
_CircleButton(
|
_CircleButton(
|
||||||
icon: _isInputExpanded ? Icons.keyboard_hide : Icons.keyboard,
|
icon: _isInputExpanded ? Icons.keyboard_hide : Icons.keyboard,
|
||||||
label: _isInputExpanded ? '收起' : '键盘',
|
label: _isInputExpanded ? '收起' : '键盘',
|
||||||
|
|
@ -1204,6 +1391,7 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
onTap: () => setState(() => _isInputExpanded = !_isInputExpanded),
|
onTap: () => setState(() => _isInputExpanded = !_isInputExpanded),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
if (!_isPttMode) ...[
|
||||||
_CircleButton(
|
_CircleButton(
|
||||||
icon: _isMuted ? Icons.mic_off : Icons.mic,
|
icon: _isMuted ? Icons.mic_off : Icons.mic,
|
||||||
label: _isMuted ? '取消静音' : '静音',
|
label: _isMuted ? '取消静音' : '静音',
|
||||||
|
|
@ -1211,6 +1399,7 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
onTap: _toggleMute,
|
onTap: _toggleMute,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
],
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
heroTag: 'end',
|
heroTag: 'end',
|
||||||
backgroundColor: AppColors.error,
|
backgroundColor: AppColors.error,
|
||||||
|
|
@ -1240,6 +1429,8 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_durationTimer?.cancel();
|
_durationTimer?.cancel();
|
||||||
_waveTimer?.cancel();
|
_waveTimer?.cancel();
|
||||||
|
_pttTimer?.cancel();
|
||||||
|
_pttStopwatch.stop();
|
||||||
_waveController.dispose();
|
_waveController.dispose();
|
||||||
_thinkingController.dispose();
|
_thinkingController.dispose();
|
||||||
_stopwatch.stop();
|
_stopwatch.stop();
|
||||||
|
|
@ -1259,12 +1450,16 @@ class _CallMessage {
|
||||||
final bool isUser;
|
final bool isUser;
|
||||||
final DateTime timestamp;
|
final DateTime timestamp;
|
||||||
final List<ChatAttachment>? attachments;
|
final List<ChatAttachment>? attachments;
|
||||||
|
final bool isVoice;
|
||||||
|
final int voiceDurationMs;
|
||||||
|
|
||||||
_CallMessage({
|
_CallMessage({
|
||||||
required this.text,
|
required this.text,
|
||||||
required this.isUser,
|
required this.isUser,
|
||||||
required this.timestamp,
|
required this.timestamp,
|
||||||
this.attachments,
|
this.attachments,
|
||||||
|
this.isVoice = false,
|
||||||
|
this.voiceDurationMs = 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue