fix(voice): keep LiveKit TTS tracks alive

This commit is contained in:
Yahya Alhinai
2026-06-28 07:20:54 +00:00
parent 46d277d203
commit c4c65f8802
91 changed files with 16863 additions and 21 deletions
@@ -0,0 +1,178 @@
import { useCallback, useMemo } from 'react';
import { Track } from 'livekit-client';
import {
type TrackReferenceOrPlaceholder,
useLocalParticipant,
useLocalParticipantPermissions,
usePersistentUserChoices,
useTrackToggle,
} from '@livekit/components-react';
const trackSourceToProtocol = (source: Track.Source) => {
// NOTE: this mapping avoids importing the protocol package as that leads to a significant bundle size increase
switch (source) {
case Track.Source.Camera:
return 1;
case Track.Source.Microphone:
return 2;
case Track.Source.ScreenShare:
return 3;
default:
return 0;
}
};
export interface PublishPermissions {
camera: boolean;
microphone: boolean;
screenShare: boolean;
data: boolean;
}
export function usePublishPermissions(): PublishPermissions {
const localPermissions = useLocalParticipantPermissions();
const canPublishSource = (source: Track.Source) => {
return (
!!localPermissions?.canPublish &&
(localPermissions.canPublishSources.length === 0 ||
localPermissions.canPublishSources.includes(trackSourceToProtocol(source)))
);
};
return {
camera: canPublishSource(Track.Source.Camera),
microphone: canPublishSource(Track.Source.Microphone),
screenShare: canPublishSource(Track.Source.ScreenShare),
data: localPermissions?.canPublishData ?? false,
};
}
export interface UseInputControlsProps {
saveUserChoices?: boolean;
onDisconnect?: () => void;
onDeviceError?: (error: { source: Track.Source; error: Error }) => void;
}
export interface UseInputControlsReturn {
micTrackRef?: TrackReferenceOrPlaceholder;
microphoneToggle: ReturnType<typeof useTrackToggle<Track.Source.Microphone>>;
cameraToggle: ReturnType<typeof useTrackToggle<Track.Source.Camera>>;
screenShareToggle: ReturnType<typeof useTrackToggle<Track.Source.ScreenShare>>;
handleAudioDeviceChange: (deviceId: string) => void;
handleVideoDeviceChange: (deviceId: string) => void;
handleMicrophoneDeviceSelectError: (error: Error) => void;
handleCameraDeviceSelectError: (error: Error) => void;
}
export function useInputControls({
saveUserChoices = true,
onDeviceError,
}: UseInputControlsProps = {}): UseInputControlsReturn {
const microphoneToggle = useTrackToggle({
source: Track.Source.Microphone,
onDeviceError: (error) => onDeviceError?.({ source: Track.Source.Microphone, error }),
});
const cameraToggle = useTrackToggle({
source: Track.Source.Camera,
onDeviceError: (error) => onDeviceError?.({ source: Track.Source.Camera, error }),
});
const screenShareToggle = useTrackToggle({
source: Track.Source.ScreenShare,
onDeviceError: (error) => onDeviceError?.({ source: Track.Source.ScreenShare, error }),
});
const { microphoneTrack, localParticipant } = useLocalParticipant();
const micTrackRef = useMemo(() => {
return localParticipant && microphoneTrack
? {
participant: localParticipant,
source: Track.Source.Microphone,
publication: microphoneTrack,
}
: undefined;
}, [localParticipant, microphoneTrack]);
const {
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
} = usePersistentUserChoices({ preventSave: !saveUserChoices });
const handleAudioDeviceChange = useCallback(
(deviceId: string) => {
saveAudioInputDeviceId(deviceId ?? 'default');
},
[saveAudioInputDeviceId]
);
const handleVideoDeviceChange = useCallback(
(deviceId: string) => {
saveVideoInputDeviceId(deviceId ?? 'default');
},
[saveVideoInputDeviceId]
);
const handleToggleCamera = useCallback(
async (enabled?: boolean) => {
if (screenShareToggle.enabled) {
screenShareToggle.toggle(false);
}
await cameraToggle.toggle(enabled);
// persist video input enabled preference
saveVideoInputEnabled(!cameraToggle.enabled);
},
[cameraToggle, screenShareToggle, saveVideoInputEnabled]
);
const handleToggleMicrophone = useCallback(
async (enabled?: boolean) => {
await microphoneToggle.toggle(enabled);
// persist audio input enabled preference
saveAudioInputEnabled(!microphoneToggle.enabled);
},
[microphoneToggle, saveAudioInputEnabled]
);
const handleToggleScreenShare = useCallback(
async (enabled?: boolean) => {
if (cameraToggle.enabled) {
cameraToggle.toggle(false);
}
await screenShareToggle.toggle(enabled);
},
[cameraToggle, screenShareToggle]
);
const handleMicrophoneDeviceSelectError = useCallback(
(error: Error) => onDeviceError?.({ source: Track.Source.Microphone, error }),
[onDeviceError]
);
const handleCameraDeviceSelectError = useCallback(
(error: Error) => onDeviceError?.({ source: Track.Source.Camera, error }),
[onDeviceError]
);
return {
micTrackRef,
cameraToggle: {
...cameraToggle,
toggle: handleToggleCamera,
},
microphoneToggle: {
...microphoneToggle,
toggle: handleToggleMicrophone,
},
screenShareToggle: {
...screenShareToggle,
toggle: handleToggleScreenShare,
},
handleAudioDeviceChange,
handleVideoDeviceChange,
handleMicrophoneDeviceSelectError,
handleCameraDeviceSelectError,
};
}