feat(video-section): in-call preview attaches to LK track
Two-mode preview: in-call attaches to the LiveKit local camera track (no double-capture); pre-call uses getUserMedia. Modes transition reactively on isCameraOn changes.
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Track } from 'livekit-client';
|
||||||
import { useVoiceStore } from '../../../stores/voiceStore';
|
import { useVoiceStore } from '../../../stores/voiceStore';
|
||||||
|
import { getActiveRoom } from '../../../hooks/useLiveKit';
|
||||||
import { isElectron, getElectronAPI } from '../../../platform/platform';
|
import { isElectron, getElectronAPI } from '../../../platform/platform';
|
||||||
|
|
||||||
type PermissionState = 'unknown' | 'probing' | 'granted' | 'initial-denied' | 'hard-blocked';
|
type PermissionState = 'unknown' | 'probing' | 'granted' | 'initial-denied' | 'hard-blocked';
|
||||||
@@ -56,6 +58,7 @@ function buildDisplayLabels(devices: MediaDeviceInfo[]): Map<string, string> {
|
|||||||
export function VideoSection() {
|
export function VideoSection() {
|
||||||
const cameraDeviceId = useVoiceStore((s) => s.cameraDeviceId);
|
const cameraDeviceId = useVoiceStore((s) => s.cameraDeviceId);
|
||||||
const setCameraDeviceId = useVoiceStore((s) => s.setCameraDeviceId);
|
const setCameraDeviceId = useVoiceStore((s) => s.setCameraDeviceId);
|
||||||
|
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||||
|
|
||||||
const [permState, setPermState] = useState<PermissionState>('unknown');
|
const [permState, setPermState] = useState<PermissionState>('unknown');
|
||||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||||
@@ -137,19 +140,69 @@ export function VideoSection() {
|
|||||||
};
|
};
|
||||||
}, [permState]);
|
}, [permState]);
|
||||||
|
|
||||||
// Pre-call preview: start a getUserMedia stream for the selected device whenever
|
// Dual-mode preview:
|
||||||
// permission is granted. Swap source when cameraDeviceId changes.
|
// - In-call: if a LiveKit local Camera publication exists with a live MediaStreamTrack,
|
||||||
|
// attach that track to the <video> element (no parallel getUserMedia — avoids
|
||||||
|
// NotReadableError on platforms with exclusive-camera semantics, and avoids
|
||||||
|
// double-capture / the camera light being lit twice on platforms that allow it).
|
||||||
|
// - Pre-call: open getUserMedia for the selected device.
|
||||||
|
// Reactive on isCameraOn so flipping camera mid-session swaps modes cleanly.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (permState !== 'granted') return;
|
if (permState !== 'granted') return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const start = async () => {
|
// Stop the pre-call getUserMedia stream we own. Releases the camera light.
|
||||||
// Stop any prior stream before requesting a new one.
|
const stopPreCall = () => {
|
||||||
if (previewStreamRef.current) {
|
if (previewStreamRef.current) {
|
||||||
previewStreamRef.current.getTracks().forEach((t) => t.stop());
|
previewStreamRef.current.getTracks().forEach((t) => t.stop());
|
||||||
previewStreamRef.current = null;
|
previewStreamRef.current = null;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detach an in-call LK track. Never call mst.stop() — the publication is
|
||||||
|
// still being consumed by other call participants.
|
||||||
|
const detachInCall = () => {
|
||||||
|
const videoEl = previewVideoRef.current;
|
||||||
|
if (videoEl) videoEl.srcObject = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fullStop = () => {
|
||||||
|
stopPreCall();
|
||||||
|
detachInCall();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns true iff an LK camera track was successfully attached to the preview.
|
||||||
|
// Returns false (never throws) when no room/publication exists or the track is dead,
|
||||||
|
// so the pre-call fallback can run cleanly.
|
||||||
|
const tryInCall = (): boolean => {
|
||||||
|
const room = getActiveRoom();
|
||||||
|
if (!room) return false;
|
||||||
|
const camPub = Array.from(room.localParticipant.trackPublications.values()).find(
|
||||||
|
(pub) => pub.source === Track.Source.Camera,
|
||||||
|
);
|
||||||
|
const mst = camPub?.track?.mediaStreamTrack;
|
||||||
|
if (!mst || mst.readyState !== 'live') return false;
|
||||||
|
|
||||||
|
// Defensive: ensure we don't keep a parallel getUserMedia stream alive.
|
||||||
|
stopPreCall();
|
||||||
|
|
||||||
|
const videoEl = previewVideoRef.current;
|
||||||
|
if (!videoEl) return false;
|
||||||
|
// Wrap the LK MediaStreamTrack in a fresh MediaStream for srcObject assignment.
|
||||||
|
// This is the same pattern LiveKit's track.attach() uses internally; the
|
||||||
|
// wrapper does not duplicate or take ownership of the track.
|
||||||
|
videoEl.srcObject = new MediaStream([mst]);
|
||||||
|
videoEl.play().catch(() => {});
|
||||||
|
setPreviewError(null);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPreCall = async () => {
|
||||||
|
// Make sure no LK attachment is left over from a previous mode.
|
||||||
|
detachInCall();
|
||||||
|
// Stop any prior pre-call stream before requesting a new one.
|
||||||
|
stopPreCall();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
@@ -180,20 +233,20 @@ export function VideoSection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
start();
|
// Mode decision. tryInCall short-circuits to the pre-call fallback if the LK
|
||||||
|
// track isn't ready yet (e.g., user just toggled camera on, publication still
|
||||||
|
// negotiating).
|
||||||
|
if (isCameraOn && tryInCall()) {
|
||||||
|
// In-call mode active.
|
||||||
|
} else {
|
||||||
|
startPreCall();
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (previewStreamRef.current) {
|
fullStop();
|
||||||
previewStreamRef.current.getTracks().forEach((t) => t.stop());
|
|
||||||
previewStreamRef.current = null;
|
|
||||||
}
|
|
||||||
const videoEl = previewVideoRef.current;
|
|
||||||
if (videoEl) {
|
|
||||||
videoEl.srcObject = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [permState, cameraDeviceId]);
|
}, [permState, cameraDeviceId, isCameraOn]);
|
||||||
|
|
||||||
const displayLabels = useMemo(() => buildDisplayLabels(devices), [devices]);
|
const displayLabels = useMemo(() => buildDisplayLabels(devices), [devices]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user