refactor(camera): unify toggle paths through handleCameraAction

- Voice-bar and mobile camera buttons now use the canonical handler
  (fixes mobile no-op and voice-bar wrong-preset bugs)
- Remove dead useLiveKit.toggleCamera
- Add _intentionalCameraOff flag with mark/consume helpers
This commit is contained in:
Jannis Braun
2026-04-27 20:16:14 +02:00
parent 486448607e
commit 51ad54fda5
4 changed files with 32 additions and 32 deletions
@@ -8,6 +8,7 @@ import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextM
import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems'; import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems';
import { wsSend } from '../../hooks/useWebSocket'; import { wsSend } from '../../hooks/useWebSocket';
import { getChannelOrigin } from '../../stores/spaceStore'; import { getChannelOrigin } from '../../stores/spaceStore';
import { handleCameraAction } from '../../utils/voiceActions';
export function MobileVoiceFullScreen() { export function MobileVoiceFullScreen() {
const popMobileScreen = useUIStore((s) => s.popMobileScreen); const popMobileScreen = useUIStore((s) => s.popMobileScreen);
@@ -20,7 +21,6 @@ export function MobileVoiceFullScreen() {
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const toggleMute = useVoiceStore((s) => s.toggleMic); const toggleMute = useVoiceStore((s) => s.toggleMic);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
const leaveVoice = useVoiceStore((s) => s.leaveVoice); const leaveVoice = useVoiceStore((s) => s.leaveVoice);
const voiceUsers = useVoiceStore((s) => s.voiceUsers); const voiceUsers = useVoiceStore((s) => s.voiceUsers);
@@ -283,7 +283,7 @@ export function MobileVoiceFullScreen() {
{/* Camera */} {/* Camera */}
<button <button
onClick={toggleCamera} onClick={handleCameraAction}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${ className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
isCameraOn ? 'bg-accent-mint/20 text-accent-mint' : 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover' isCameraOn ? 'bg-accent-mint/20 text-accent-mint' : 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
}`} }`}
@@ -8,6 +8,7 @@ import { ConnectionInfoPopover } from './ConnectionInfoPopover';
import { startScreenShare, stopScreenShare } from '../../utils/screenShare'; import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { broadcastVoiceStatus } from '../../utils/voice'; import { broadcastVoiceStatus } from '../../utils/voice';
import { handleCameraAction } from '../../utils/voiceActions';
/** /**
* VoiceControls renders the voice status + button rows. * VoiceControls renders the voice status + button rows.
@@ -17,7 +18,6 @@ export function VoiceControls() {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const isCameraOn = useVoiceStore((s) => s.isCameraOn); const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled); const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled); const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled);
const connectionError = useVoiceStore((s) => s.connectionError); const connectionError = useVoiceStore((s) => s.connectionError);
@@ -44,19 +44,6 @@ export function VoiceControls() {
const channel = channels.find(c => c.id === currentVoiceChannelId); const channel = channels.find(c => c.id === currentVoiceChannelId);
const channelName = channel?.name ?? (activeDmCall ? 'DM Call' : 'Voice Channel'); const channelName = channel?.name ?? (activeDmCall ? 'DM Call' : 'Voice Channel');
const handleCamera = async () => {
const room = getActiveRoom();
if (!room) return;
try {
const willEnable = !isCameraOn;
await room.localParticipant.setCameraEnabled(willEnable);
toggleCamera();
broadcastVoiceStatus();
} catch (err) {
console.error('[VoiceControls] Failed to toggle camera:', err);
}
};
const handleScreenShare = async () => { const handleScreenShare = async () => {
const room = getActiveRoom(); const room = getActiveRoom();
console.log('[SS] handleScreenShare clicked, room:', !!room, 'isScreenSharing:', isScreenSharing); console.log('[SS] handleScreenShare clicked, room:', !!room, 'isScreenSharing:', isScreenSharing);
@@ -162,7 +149,7 @@ export function VoiceControls() {
<div className="relative flex items-center gap-1 px-3 pb-2 pt-1"> <div className="relative flex items-center gap-1 px-3 pb-2 pt-1">
{canSpeak && ( {canSpeak && (
<button <button
onClick={handleCamera} onClick={handleCameraAction}
className={`${btnBase} ${ className={`${btnBase} ${
isCameraOn isCameraOn
? 'bg-surface-base text-status-online hover:bg-surface-channel' ? 'bg-surface-base text-status-online hover:bg-surface-channel'
+1 -12
View File
@@ -22,7 +22,6 @@ import { broadcastVoiceStatus } from '../utils/voice';
import { AudioManager } from '../audio/AudioManager'; import { AudioManager } from '../audio/AudioManager';
import { SpeakingDetector } from '../audio/SpeakingDetector'; import { SpeakingDetector } from '../audio/SpeakingDetector';
import { import {
CAMERA_PRESET,
CAMERA_OVERDRIVE, CAMERA_OVERDRIVE,
buildScreenShareOptions, buildScreenShareOptions,
applyOverdrive, applyOverdrive,
@@ -644,16 +643,6 @@ export function useLiveKit() {
useVoiceStore.getState().toggleMic(); useVoiceStore.getState().toggleMic();
}, []); }, []);
const toggleCamera = useCallback(async () => {
if (roomRef.current) {
if (!isCameraOn) {
await roomRef.current.localParticipant.setCameraEnabled(true, { resolution: CAMERA_PRESET.resolution, frameRate: CAMERA_PRESET.encoding.maxFramerate }, { videoCodec: CAMERA_PRESET.codec, videoEncoding: CAMERA_PRESET.encoding, simulcast: true });
setTimeout(() => { if (roomRef.current) applyOverdrive(roomRef.current, Track.Source.Camera, CAMERA_OVERDRIVE); }, 2000);
} else { await roomRef.current.localParticipant.setCameraEnabled(false); }
updateParticipants();
}
}, [isCameraOn, updateParticipants]);
const toggleScreenShare = useCallback(async () => { const toggleScreenShare = useCallback(async () => {
if (!roomRef.current) return; if (!roomRef.current) return;
if (!useVoiceStore.getState().isScreenSharing) { if (!useVoiceStore.getState().isScreenSharing) {
@@ -721,5 +710,5 @@ export function useLiveKit() {
}, []); }, []);
return { room, isConnected, isConnecting, connectionState, connectedChannelId, connectionError, connect, disconnect, toggleMic, toggleCamera, toggleScreenShare }; return { room, isConnected, isConnecting, connectionState, connectedChannelId, connectionError, connect, disconnect, toggleMic, toggleScreenShare };
} }
+27 -3
View File
@@ -6,6 +6,25 @@ import { getChannelOrigin } from '../stores/spaceStore';
import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from './voice'; import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from './voice';
import { CAMERA_PRESET, startScreenShare, stopScreenShare } from './screenShare'; import { CAMERA_PRESET, startScreenShare, stopScreenShare } from './screenShare';
/**
* One-shot flag used to distinguish user-initiated camera-off from unexpected
* track-end events (hardware unplug, OS permission revoke). Set right before
* `setCameraEnabled(false)` in any deliberate disable path; consumed-and-cleared
* by the camera-track `ended` handler in useLiveKit. Module-level by design:
* never persisted, never on the store, single producer + single consumer.
*/
let _intentionalCameraOff = false;
export function markIntentionalCameraOff(): void {
_intentionalCameraOff = true;
}
export function consumeIntentionalCameraOff(): boolean {
const v = _intentionalCameraOff;
_intentionalCameraOff = false;
return v;
}
/** /**
* Toggle mute. Respects space-mute/deafen guards. * Toggle mute. Respects space-mute/deafen guards.
* Extracted from VoiceControlBar so keybinds and buttons share the same logic. * Extracted from VoiceControlBar so keybinds and buttons share the same logic.
@@ -31,7 +50,8 @@ export function handleDeafenAction(isSpaceDeafened: boolean): void {
} }
/** /**
* Toggle camera. Requires LiveKit room. * Toggle camera. Requires LiveKit room. Sole canonical camera-toggle path —
* the voice-bar button, mobile button, and keybind all funnel through here.
*/ */
export async function handleCameraAction(): Promise<void> { export async function handleCameraAction(): Promise<void> {
const room = getActiveRoom(); const room = getActiveRoom();
@@ -40,8 +60,9 @@ export async function handleCameraAction(): Promise<void> {
try { try {
const willEnable = !isCameraOn; const willEnable = !isCameraOn;
if (willEnable) { if (willEnable) {
await room.localParticipant.setCameraEnabled(true, await room.localParticipant.setCameraEnabled(
{ resolution: CAMERA_PRESET.resolution }, true,
{ resolution: CAMERA_PRESET.resolution, frameRate: CAMERA_PRESET.encoding.maxFramerate },
{ {
videoCodec: CAMERA_PRESET.codec, videoCodec: CAMERA_PRESET.codec,
videoEncoding: CAMERA_PRESET.encoding, videoEncoding: CAMERA_PRESET.encoding,
@@ -49,6 +70,9 @@ export async function handleCameraAction(): Promise<void> {
} }
); );
} else { } else {
// Mark this disable as intentional so the track-`ended` handler skips
// its unplug/permission-revoke probe + toast.
markIntentionalCameraOff();
await room.localParticipant.setCameraEnabled(false); await room.localParticipant.setCameraEnabled(false);
} }
useVoiceStore.getState().toggleCamera(); useVoiceStore.getState().toggleCamera();