mirror of https://github.com/OpenVidu/openvidu.git
ov-components: Enhances camera/mic switching in prejoin
Refactors camera and microphone switching logic in the prejoin state. Uses `restartTrack` to preserve track settings and background processor state. Improves background effect handling during camera changes. Creates new tracks only when necessary (camera unavailable). Ensures proper muting behavior based on device settings.pull/860/head
parent
5de74f2567
commit
4617dfd797
|
|
@ -3,7 +3,7 @@ import {
|
||||||
BackgroundProcessor,
|
BackgroundProcessor,
|
||||||
supportsBackgroundProcessors,
|
supportsBackgroundProcessors,
|
||||||
supportsModernBackgroundProcessors,
|
supportsModernBackgroundProcessors,
|
||||||
/*BackgroundProcessorWrapper,*/ SwitchBackgroundProcessorOptions
|
/*BackgroundProcessorWrapper,*/ SwitchBackgroundProcessorOptions
|
||||||
} from '@livekit/track-processors';
|
} from '@livekit/track-processors';
|
||||||
import {
|
import {
|
||||||
AudioCaptureOptions,
|
AudioCaptureOptions,
|
||||||
|
|
@ -77,6 +77,11 @@ export class OpenViduService {
|
||||||
*/
|
*/
|
||||||
readonly isBackgroundProcessorSupported: Signal<boolean> = this._isBackgroundProcessorSupported.asReadonly();
|
readonly isBackgroundProcessorSupported: Signal<boolean> = this._isBackgroundProcessorSupported.asReadonly();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the last applied background options so they can be re-applied after a camera switch.
|
||||||
|
*/
|
||||||
|
private currentBackgroundOptions: SwitchBackgroundProcessorOptions | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
|
|
@ -84,7 +89,7 @@ export class OpenViduService {
|
||||||
private loggerSrv: LoggerService,
|
private loggerSrv: LoggerService,
|
||||||
private deviceService: DeviceService,
|
private deviceService: DeviceService,
|
||||||
private storageService: StorageService,
|
private storageService: StorageService,
|
||||||
private configService: OpenViduComponentsConfigService,
|
private configService: OpenViduComponentsConfigService
|
||||||
) {
|
) {
|
||||||
this.log = this.loggerSrv.get('OpenViduService');
|
this.log = this.loggerSrv.get('OpenViduService');
|
||||||
// this.isSttReadyObs = this._isSttReady.asObservable();
|
// this.isSttReadyObs = this._isSttReady.asObservable();
|
||||||
|
|
@ -313,8 +318,6 @@ export class OpenViduService {
|
||||||
return this.localTracks;
|
return this.localTracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switches the background mode on the local video track.
|
* Switches the background mode on the local video track.
|
||||||
* Works both in prejoin and in-room states.
|
* Works both in prejoin and in-room states.
|
||||||
|
|
@ -339,6 +342,7 @@ export class OpenViduService {
|
||||||
// If processor exists, switch mode (either pre-initialized or just created on-demand)
|
// If processor exists, switch mode (either pre-initialized or just created on-demand)
|
||||||
if (this.backgroundProcessor) {
|
if (this.backgroundProcessor) {
|
||||||
await this.backgroundProcessor.switchTo(options);
|
await this.backgroundProcessor.switchTo(options);
|
||||||
|
this.currentBackgroundOptions = options;
|
||||||
this.log.d('Background mode switched:', options);
|
this.log.d('Background mode switched:', options);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -460,25 +464,12 @@ export class OpenViduService {
|
||||||
newLocalTracks = await createLocalTracks(options);
|
newLocalTracks = await createLocalTracks(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply background processor to video track (initialized in disabled mode)
|
// Apply background processor to the new video track.
|
||||||
// For browsers with modern processor support: attach processor immediately for smooth transitions
|
// applyProcessorToVideoTrack handles both modern (pre-attach + auto-restore via
|
||||||
// For browsers without modern support: skip attachment, will be applied on-demand when effect is activated
|
// transformer.options) and Firefox/non-modern (lazy attach only when a VBG is active).
|
||||||
const videoTrack = newLocalTracks.find((t) => t.kind === Track.Kind.Video) as LocalVideoTrack | undefined;
|
const videoTrack = newLocalTracks.find((t) => t.kind === Track.Kind.Video) as LocalVideoTrack | undefined;
|
||||||
if (videoTrack && supportsModernBackgroundProcessors()) {
|
if (videoTrack) {
|
||||||
if (this.isBackgroundProcessorSupported() && this.backgroundProcessor) {
|
await this.applyProcessorToVideoTrack(videoTrack);
|
||||||
try {
|
|
||||||
await videoTrack.setProcessor(this.backgroundProcessor);
|
|
||||||
this.log.d('Background processor applied to newly created video track');
|
|
||||||
} catch (error: any) {
|
|
||||||
this.log.w('Failed to apply background processor (GPU may be disabled):', error?.message || error);
|
|
||||||
this._isBackgroundProcessorSupported.set(false);
|
|
||||||
// Continue without crashing - virtual background will be disabled
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.log.d('Background processor not supported (GPU disabled or not available)');
|
|
||||||
}
|
|
||||||
} else if (videoTrack && !supportsModernBackgroundProcessors()) {
|
|
||||||
this.log.d('Modern background processors not supported - will apply processor on-demand when effect is activated');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mute tracks if devices are disabled
|
// Mute tracks if devices are disabled
|
||||||
|
|
@ -584,93 +575,147 @@ export class OpenViduService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch the camera device when the room is not connected (prejoin page)
|
* Switches the camera device in prejoin (room not yet connected).
|
||||||
* @param deviceId new video device to use
|
*
|
||||||
|
* Uses `LocalVideoTrack.restartTrack({ deviceId })` on the existing track when available.
|
||||||
|
* This is the correct LiveKit pattern: `restartTrack` internally calls `setMediaStreamTrack`,
|
||||||
|
* which automatically calls `processor.restart(newTrack)` if a background processor is
|
||||||
|
* attached — preserving any active virtual-background effect without extra work.
|
||||||
|
*
|
||||||
|
* Falls back to creating a new track (with processor reattachment) when no track exists.
|
||||||
|
* @param deviceId - The new video device ID
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
async switchCamera(deviceId: string): Promise<void> {
|
async switchCamera(deviceId: string): Promise<void> {
|
||||||
const existingTrack = this.localTracks.find((track) => track.kind === Track.Kind.Video) as LocalVideoTrack;
|
const existingTrack = this.localTracks.find((t) => t.kind === Track.Kind.Video) as LocalVideoTrack | undefined;
|
||||||
|
|
||||||
if (existingTrack) {
|
if (existingTrack) {
|
||||||
//TODO: Should use replace track using restartTrack
|
try {
|
||||||
// Try to restart existing track
|
// restartTrack replaces the underlying MediaStreamTrack in-place.
|
||||||
this.removeVideoTrack();
|
// LiveKit's setMediaStreamTrack will call processor.restart(newTrack) automatically
|
||||||
// try {
|
// if a background processor is attached, preserving the active effect.
|
||||||
// await existingTrack.restartTrack({ deviceId: deviceId });
|
await existingTrack.restartTrack({ deviceId });
|
||||||
// this.log.d('Camera switched successfully using existing track');
|
if (!this.deviceService.isCameraEnabled()) {
|
||||||
// return;
|
await existingTrack.mute();
|
||||||
// } catch (error) {
|
}
|
||||||
// this.log.w('Failed to restart video track, trying to create new one:', error);
|
this.log.d('Camera switched via restartTrack:', deviceId);
|
||||||
// // Remove the failed track
|
} catch (error) {
|
||||||
// this.removeVideoTrack();
|
this.log.e('Failed to switch camera via restartTrack:', error);
|
||||||
// }
|
throw error;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new video track if no existing track or restart failed
|
// No existing track (edge case: camera was unavailable/unpublished) → create a fresh one
|
||||||
try {
|
try {
|
||||||
const newVideoTracks = await createLocalTracks({
|
const newVideoTracks = await createLocalTracks({ video: { deviceId } });
|
||||||
video: { deviceId: deviceId }
|
const videoTrack = newVideoTracks.find((t) => t.kind === Track.Kind.Video) as LocalVideoTrack | undefined;
|
||||||
});
|
|
||||||
|
|
||||||
const videoTrack = newVideoTracks.find((t) => t.kind === Track.Kind.Video);
|
|
||||||
if (videoTrack) {
|
if (videoTrack) {
|
||||||
// Mute if camera is disabled in settings
|
|
||||||
if (!this.deviceService.isCameraEnabled()) {
|
if (!this.deviceService.isCameraEnabled()) {
|
||||||
await videoTrack.mute();
|
await videoTrack.mute();
|
||||||
}
|
}
|
||||||
|
// Attach processor (and restore active background if any) to the fresh track
|
||||||
|
await this.applyProcessorToVideoTrack(videoTrack);
|
||||||
this.localTracks.push(videoTrack);
|
this.localTracks.push(videoTrack);
|
||||||
this.log.d('New camera track created and added');
|
this.log.d('New camera track created and added:', deviceId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log.e('Failed to create new video track:', error);
|
this.log.e('Failed to create new video track:', error);
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
throw new Error(`Failed to switch camera: ${message}`);
|
throw new Error(`Failed to switch camera: ${message}`);
|
||||||
}
|
}
|
||||||
} /**
|
}
|
||||||
* Switches the microphone device when the room is not connected (prejoin page)
|
|
||||||
* @param deviceId new audio device to use
|
/**
|
||||||
|
* Attaches the background processor to a freshly-created video track.
|
||||||
|
* Called only for brand-new track objects (createLocalTracks or the no-existing-track fallback).
|
||||||
|
*
|
||||||
|
* - Modern browsers: pre-attaches the shared processor object; `processor.init()` uses the
|
||||||
|
* transformer's stored options so any previously active mode is automatically restored.
|
||||||
|
* - Firefox (non-modern / stream fallback): lazily attaches the processor only when a
|
||||||
|
* background effect was already active, then re-applies the stored options.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
private async applyProcessorToVideoTrack(videoTrack: LocalVideoTrack): Promise<void> {
|
||||||
|
if (!this.isBackgroundProcessorSupported()) return;
|
||||||
|
|
||||||
|
if (supportsModernBackgroundProcessors()) {
|
||||||
|
if (!this.backgroundProcessor) return;
|
||||||
|
try {
|
||||||
|
// setProcessor calls processor.init() which re-initialises the pipeline using
|
||||||
|
// transformer.options (updated by every switchTo call), so the active background
|
||||||
|
// effect is restored without an explicit switchTo here.
|
||||||
|
await videoTrack.setProcessor(this.backgroundProcessor);
|
||||||
|
this.log.d('Background processor applied to video track');
|
||||||
|
} catch (error: any) {
|
||||||
|
this.log.w('Failed to apply background processor to video track:', error?.message || error);
|
||||||
|
this._isBackgroundProcessorSupported.set(false);
|
||||||
|
}
|
||||||
|
} else if (this.currentBackgroundOptions && this.currentBackgroundOptions.mode !== 'disabled') {
|
||||||
|
// Firefox / non-modern: processor is not pre-allocated; create on first use
|
||||||
|
try {
|
||||||
|
if (!this.backgroundProcessor) {
|
||||||
|
this.backgroundProcessor = BackgroundProcessor({ mode: 'disabled' });
|
||||||
|
}
|
||||||
|
await videoTrack.setProcessor(this.backgroundProcessor);
|
||||||
|
// For the non-modern path the processor's transformer options are reset on init;
|
||||||
|
// we must explicitly re-apply the active effect.
|
||||||
|
await this.backgroundProcessor.switchTo(this.currentBackgroundOptions);
|
||||||
|
this.log.d('Background effect restored on new track (non-modern):', this.currentBackgroundOptions);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.log.w('Failed to restore background processor (non-modern):', error?.message || error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches the microphone device in prejoin (room not yet connected).
|
||||||
|
*
|
||||||
|
* Uses `LocalAudioTrack.restartTrack({ deviceId })` on the existing track when available,
|
||||||
|
* preserving echo-cancellation, noise-suppression and auto-gain-control constraints.
|
||||||
|
* Falls back to creating a new audio track when none exists.
|
||||||
|
* @param deviceId - The new audio device ID
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
async switchMicrophone(deviceId: string): Promise<void> {
|
async switchMicrophone(deviceId: string): Promise<void> {
|
||||||
const existingTrack = this.localTracks?.find((track) => track.kind === Track.Kind.Audio) as LocalAudioTrack;
|
const existingTrack = this.localTracks.find((t) => t.kind === Track.Kind.Audio) as LocalAudioTrack | undefined;
|
||||||
|
|
||||||
if (existingTrack) {
|
if (existingTrack) {
|
||||||
this.removeAudioTrack();
|
try {
|
||||||
//TODO: Should use replace track using restartTrack
|
await existingTrack.restartTrack({
|
||||||
// Try to restart existing track
|
deviceId,
|
||||||
// try {
|
echoCancellation: true,
|
||||||
// await existingTrack.restartTrack({ deviceId: deviceId });
|
noiseSuppression: true,
|
||||||
// this.log.d('Microphone switched successfully using existing track');
|
autoGainControl: true
|
||||||
// return;
|
});
|
||||||
// } catch (error) {
|
if (!this.deviceService.isMicrophoneEnabled()) {
|
||||||
// this.log.w('Failed to restart audio track, trying to create new one:', error);
|
await existingTrack.mute();
|
||||||
// // Remove the failed track
|
}
|
||||||
// this.removeAudioTrack();
|
this.log.d('Microphone switched via restartTrack:', deviceId);
|
||||||
// }
|
} catch (error) {
|
||||||
|
this.log.e('Failed to switch microphone via restartTrack:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new audio track if no existing track or restart failed
|
// No existing track (edge case) → create a fresh one
|
||||||
try {
|
try {
|
||||||
const newAudioTracks = await createLocalTracks({
|
const newAudioTracks = await createLocalTracks({
|
||||||
audio: {
|
audio: {
|
||||||
deviceId: deviceId,
|
deviceId,
|
||||||
echoCancellation: true,
|
echoCancellation: true,
|
||||||
noiseSuppression: true,
|
noiseSuppression: true,
|
||||||
autoGainControl: true
|
autoGainControl: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const audioTrack = newAudioTracks.find((t) => t.kind === Track.Kind.Audio);
|
const audioTrack = newAudioTracks.find((t) => t.kind === Track.Kind.Audio);
|
||||||
if (audioTrack) {
|
if (audioTrack) {
|
||||||
this.localTracks.push(audioTrack);
|
|
||||||
|
|
||||||
// Mute if microphone is disabled in settings
|
|
||||||
if (!this.deviceService.isMicrophoneEnabled()) {
|
if (!this.deviceService.isMicrophoneEnabled()) {
|
||||||
await audioTrack.mute();
|
await audioTrack.mute();
|
||||||
}
|
}
|
||||||
|
this.localTracks.push(audioTrack);
|
||||||
this.log.d('New microphone track created and added');
|
this.log.d('New microphone track created and added:', deviceId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log.e('Failed to create new audio track:', error);
|
this.log.e('Failed to create new audio track:', error);
|
||||||
|
|
@ -679,36 +724,6 @@ export class OpenViduService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes video track from local tracks
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
private removeVideoTrack(): void {
|
|
||||||
const videoTrackIndex = this.localTracks.findIndex((track) => track.kind === Track.Kind.Video);
|
|
||||||
if (videoTrackIndex !== -1) {
|
|
||||||
const videoTrack = this.localTracks[videoTrackIndex];
|
|
||||||
videoTrack.stop();
|
|
||||||
videoTrack.detach();
|
|
||||||
this.localTracks.splice(videoTrackIndex, 1);
|
|
||||||
this.log.d('Video track removed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes audio track from local tracks
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
private removeAudioTrack(): void {
|
|
||||||
const audioTrackIndex = this.localTracks.findIndex((track) => track.kind === Track.Kind.Audio);
|
|
||||||
if (audioTrackIndex !== -1) {
|
|
||||||
const audioTrack = this.localTracks[audioTrackIndex];
|
|
||||||
audioTrack.stop();
|
|
||||||
audioTrack.detach();
|
|
||||||
this.localTracks.splice(audioTrackIndex, 1);
|
|
||||||
this.log.d('Audio track removed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the current video track from local tracks or room
|
* Gets the current video track from local tracks or room
|
||||||
* @returns LocalVideoTrack or undefined
|
* @returns LocalVideoTrack or undefined
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue