From 0fba47cec8fca3b6ab5d37d5e93c7fd994bebe44 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 5 Feb 2026 21:41:02 -0800 Subject: [PATCH] RD-T39 Fixing PTT issue --- .agent/rules/agent.md | 27 ++++++++ .../dispatch-console/ptt-interface.tsx | 35 +++------- src/stores/app/livekit-store.ts | 67 ++++++++++++++++++- 3 files changed, 104 insertions(+), 25 deletions(-) diff --git a/.agent/rules/agent.md b/.agent/rules/agent.md index ad482a6..bd89348 100644 --- a/.agent/rules/agent.md +++ b/.agent/rules/agent.md @@ -1,3 +1,7 @@ +--- +trigger: always_on +--- + You are an expert in TypeScript, React Native, Expo, and Mobile App Development. Code Style and Structure: @@ -71,3 +75,26 @@ Additional Rules: - Use `@rnmapbox/maps` for maps, mapping or vehicle navigation - Use `lucide-react-native` for icons and use those components directly in the markup and don't use the gluestack-ui icon component - Use ? : for conditional rendering and not && + +Be more strict about planning. + + +Do not say things or provide incorrect information just to be polite; certainty is required. + + +When solving problems, always analyze them through first principles thinking. Break every challenge down to its basic, fundamental truths and build your solutions from the ground up rather than relying on analogies or common practices. + + +When debugging, always investigate whether legacy code or previous implementations are interfering with new logic before assuming the new code is inherently broken. + + +**Anti-Repetition Protocol** +: If a previously suggested fix is reported as failed, do not attempt to "patch" the broken logic or repeat the same suggestion. Instead, explicitly discard your previous assumptions, re-verify the data flow from first principles, and propose a fundamentally different architectural path. Avoid repetition bias at all costs. + + +**Token Efficiency Protocol** +: Be extremely concise. Prioritize code and technical facts over conversational filler. + + +**Pre-Flight Verification** +: Always verify the current state of relevant files, imports, and the specific environment (e.g., Windows paths, Node version) BEFORE proposing a change. The goal is to maximize the success rate of the first attempt. \ No newline at end of file diff --git a/src/components/dispatch-console/ptt-interface.tsx b/src/components/dispatch-console/ptt-interface.tsx index 93082d6..3a9ad15 100644 --- a/src/components/dispatch-console/ptt-interface.tsx +++ b/src/components/dispatch-console/ptt-interface.tsx @@ -1,4 +1,4 @@ -import { Mic, MicOff, PhoneOff, Radio, Wifi, WifiOff } from 'lucide-react-native'; +import { PhoneOff, Radio, Wifi, WifiOff } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -64,7 +64,6 @@ export const PTTInterface: React.FC = ({ onPTTPress, onPTTRel disconnect, startTransmitting, stopTransmitting, - toggleMute, selectChannel, refreshVoiceSettings, } = usePTT({ @@ -78,7 +77,8 @@ export const PTTInterface: React.FC = ({ onPTTPress, onPTTRel const isTransmitting = isConnected ? pttTransmitting : externalTransmitting; const displayChannel = isConnected ? (pttChannel?.Name || externalChannel) : (pttChannel?.Name || t('dispatch.disconnected')); - const handlePTTPress = useCallback(async () => { + // Toggle-style PTT handler - tap to start/stop transmitting + const handlePTTToggle = useCallback(async () => { if (!isConnected) { // If not connected, try to connect first if (availableChannels.length > 0 && !pttChannel) { @@ -92,18 +92,13 @@ export const PTTInterface: React.FC = ({ onPTTPress, onPTTRel return; } - await startTransmitting(); - }, [isConnected, availableChannels, pttChannel, selectChannel, connect, startTransmitting]); - - const handlePTTRelease = useCallback(async () => { - if (isConnected && pttTransmitting) { + // Toggle transmitting state + if (pttTransmitting) { await stopTransmitting(); + } else { + await startTransmitting(); } - }, [isConnected, pttTransmitting, stopTransmitting]); - - const handleMuteToggle = useCallback(async () => { - await toggleMute(); - }, [toggleMute]); + }, [isConnected, availableChannels, pttChannel, pttTransmitting, selectChannel, connect, startTransmitting, stopTransmitting]); const handleChannelPress = useCallback(() => { if (isVoiceEnabled && availableChannels.length > 0) { @@ -210,17 +205,8 @@ export const PTTInterface: React.FC = ({ onPTTPress, onPTTRel ) : null} - {/* Mute Button */} - - - - - {/* PTT Button */} - + {/* PTT Toggle Button - tap to start/stop transmitting */} + @@ -254,6 +240,7 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + // Note: mutedButton style kept for backwards compatibility but no longer used mutedButton: { backgroundColor: 'rgba(239, 68, 68, 0.1)', }, diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index bf12de9..ce13d19 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -1,6 +1,6 @@ import notifee, { AndroidImportance } from '@notifee/react-native'; import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio'; -import { Room, RoomEvent } from 'livekit-client'; +import { Room, RoomEvent, Track } from 'livekit-client'; import { Platform } from 'react-native'; import { create } from 'zustand'; @@ -57,6 +57,8 @@ const setupAudioRouting = async (room: Room): Promise => { } }; +// Map to store web audio elements for cleanup (keyed by track SID) +const webAudioElements = new Map(); interface LiveKitState { // Connection state isConnected: boolean; @@ -243,6 +245,53 @@ export const useLiveKitStore = create((set, get) => ({ set({ isTalking }); }); + // Handle remote audio tracks for web platform + room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => { + // On web, attach audio tracks to DOM elements for playback + if (Platform.OS === 'web' && track.kind === Track.Kind.Audio) { + try { + const audioElement = track.attach(); + const trackSid = track.sid || publication.trackSid; + if (trackSid) { + audioElement.id = `livekit-audio-${trackSid}`; + document.body.appendChild(audioElement); + webAudioElements.set(trackSid, audioElement); + } + logger.debug({ + message: 'Attached audio track for web playback', + context: { trackSid, participantIdentity: participant.identity }, + }); + } catch (err) { + logger.error({ + message: 'Failed to attach audio track', + context: { error: err, trackSid: track.sid }, + }); + } + } + }); + + room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => { + // On web, detach and remove audio elements + if (Platform.OS === 'web' && track.kind === Track.Kind.Audio) { + try { + track.detach().forEach((el) => el.remove()); + const trackSid = track.sid || publication.trackSid; + if (trackSid) { + webAudioElements.delete(trackSid); + } + logger.debug({ + message: 'Detached audio track', + context: { trackSid, participantIdentity: participant.identity }, + }); + } catch (err) { + logger.error({ + message: 'Failed to detach audio track', + context: { error: err, trackSid: track.sid }, + }); + } + } + }); + // Connect to the room await room.connect(voipServerWebsocketSslAddress, token); @@ -294,6 +343,22 @@ export const useLiveKitStore = create((set, get) => ({ disconnectFromRoom: async () => { const { currentRoom } = get(); if (currentRoom) { + // Clean up web audio elements before disconnecting + if (Platform.OS === 'web') { + webAudioElements.forEach((audioElement, trackSid) => { + try { + audioElement.pause(); + audioElement.remove(); + } catch (err) { + logger.warn({ + message: 'Failed to clean up audio element', + context: { error: err, trackSid }, + }); + } + }); + webAudioElements.clear(); + } + await currentRoom.disconnect(); await audioService.playDisconnectedFromAudioRoomSound();