Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .agent/rules/agent.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
---
trigger: always_on
---

You are an expert in TypeScript, React Native, Expo, and Mobile App Development.

Code Style and Structure:
Expand Down Expand Up @@ -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.
35 changes: 11 additions & 24 deletions src/components/dispatch-console/ptt-interface.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -64,7 +64,6 @@
disconnect,
startTransmitting,
stopTransmitting,
toggleMute,
selectChannel,
refreshVoiceSettings,
} = usePTT({
Expand All @@ -76,9 +75,10 @@

// Use actual PTT state or fallback to external props
const isTransmitting = isConnected ? pttTransmitting : externalTransmitting;
const displayChannel = isConnected ? (pttChannel?.Name || externalChannel) : (pttChannel?.Name || t('dispatch.disconnected'));

Check warning on line 78 in src/components/dispatch-console/ptt-interface.tsx

View workflow job for this annotation

GitHub Actions / test

Replace `(pttChannel?.Name·||·externalChannel)·:·(pttChannel?.Name·||·t('dispatch.disconnected')` with `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) {
Expand All @@ -92,18 +92,13 @@
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) {
Expand Down Expand Up @@ -201,7 +196,7 @@
</HStack>

{/* Compact Controls */}
<HStack className="items-center" space="sm">

Check warning on line 199 in src/components/dispatch-console/ptt-interface.tsx

View workflow job for this annotation

GitHub Actions / test

Delete `⏎`

{/* Disconnect Button (only shown when connected) */}
{isConnected ? (
Expand All @@ -210,17 +205,8 @@
</Pressable>
) : null}

{/* Mute Button */}
<Pressable
onPress={handleMuteToggle}
style={StyleSheet.flatten([styles.compactControlButton, { backgroundColor: colorScheme === 'dark' ? '#374151' : '#e5e7eb' }, isMuted && styles.mutedButton])}
disabled={!isConnected}
>
<Icon as={isMuted ? MicOff : Mic} size="sm" color={isMuted ? '#ef4444' : !isConnected ? '#9ca3af' : colorScheme === 'dark' ? '#fff' : '#374151'} />
</Pressable>

{/* PTT Button */}
<Pressable onPressIn={handlePTTPress} onPressOut={handlePTTRelease} style={StyleSheet.flatten(getPTTButtonStyle())} disabled={!isVoiceEnabled || isMuted}>
{/* PTT Toggle Button - tap to start/stop transmitting */}
<Pressable onPress={handlePTTToggle} style={StyleSheet.flatten(getPTTButtonStyle())} disabled={!isVoiceEnabled}>
<Icon as={Radio} size="sm" color="#fff" />
</Pressable>
</HStack>
Expand Down Expand Up @@ -254,6 +240,7 @@
alignItems: 'center',
justifyContent: 'center',
},
// Note: mutedButton style kept for backwards compatibility but no longer used
mutedButton: {
backgroundColor: 'rgba(239, 68, 68, 0.1)',
},
Expand Down
67 changes: 66 additions & 1 deletion src/stores/app/livekit-store.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -57,6 +57,8 @@ const setupAudioRouting = async (room: Room): Promise<void> => {
}
};

// Map to store web audio elements for cleanup (keyed by track SID)
const webAudioElements = new Map<string, HTMLAudioElement>();
interface LiveKitState {
// Connection state
isConnected: boolean;
Expand Down Expand Up @@ -243,6 +245,53 @@ export const useLiveKitStore = create<LiveKitState>((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 },
});
}
}
});
Comment on lines +273 to +293
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Static analysis: forEach callback should not return a value.

el.remove() returns void, but the concise arrow (el) => el.remove() still constitutes an implicit return, which Biome flags as suspicious in an iterable callback. Use a block body.

Proposed fix
-            track.detach().forEach((el) => el.remove());
+            track.detach().forEach((el) => { el.remove(); });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 },
});
}
}
});
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 },
});
}
}
});
🧰 Tools
🪛 Biome (2.3.13)

[error] 277-277: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@src/stores/app/livekit-store.ts` around lines 273 - 293, The forEach callback
passed to track.detach() uses a concise arrow that implicitly returns a value
(el => el.remove()), which Biome flags; change it to a block-bodied callback so
it does not return anything—for example in the RoomEvent.TrackUnsubscribed
handler where track.detach().forEach(...) replace the concise arrow with a
block: (el) => { el.remove(); } (or a function(el) { el.remove(); }) to ensure
the callback returns void and avoid the static analysis warning.


// Connect to the room
await room.connect(voipServerWebsocketSslAddress, token);

Expand Down Expand Up @@ -294,6 +343,22 @@ export const useLiveKitStore = create<LiveKitState>((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();

Expand Down