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
598 changes: 359 additions & 239 deletions hooks/useBrowserSourceClient.ts

Large diffs are not rendered by default.

153 changes: 153 additions & 0 deletions lib/browser-audio-gate-device.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { MediaGateDevice, MediaGateDeviceState } from './media-gate-executor';

export type BrowserAudioGateTrack = {
readonly mediaStreamTrack: {
enabled: boolean;
readonly readyState?: MediaStreamTrackState;
};
readonly isMuted: boolean;
mute(): Promise<unknown>;
unmute(): Promise<unknown>;
};

export type BrowserAudioGateBinding = {
readonly track: BrowserAudioGateTrack;
readonly publication: object;
};

export type BrowserAudioGateDeviceOptions = {
readonly getBinding: () => BrowserAudioGateBinding | null;
readonly ensurePublishedClosed: (signal: AbortSignal) => Promise<void>;
};

export class BrowserAudioGateDevice implements MediaGateDevice {
private readonly getBinding: BrowserAudioGateDeviceOptions['getBinding'];
private readonly ensurePublishedClosed: BrowserAudioGateDeviceOptions['ensurePublishedClosed'];
private readonly signalingTails = new WeakMap<BrowserAudioGateTrack, Promise<void>>();
private generation = 0;
private forcedClosed = true;

constructor(options: BrowserAudioGateDeviceOptions) {
this.getBinding = options.getBinding;
this.ensurePublishedClosed = options.ensurePublishedClosed;
}

close(): void {
this.generation += 1;
this.forcedClosed = true;
const binding = this.getBinding();
if (!binding) return;
disableCapture(binding);
this.queueMute(binding);
}

async open(signal: AbortSignal): Promise<void> {
const generation = this.generation;
throwIfAborted(signal);
await this.ensurePublishedClosed(signal);
throwIfAborted(signal);

const binding = this.getBinding();
if (!binding) throw new Error('browser audio track is not published');
const { track } = binding;
track.mediaStreamTrack.enabled = false;

try {
await this.enqueueSignaling(track, async () => {
this.requireCurrent(binding, generation, signal);
const unmute = track.unmute();
// LiveKit may synchronously toggle the underlying MediaStreamTrack. Keep capture
// closed until both unmute and all cancellation checks have completed.
track.mediaStreamTrack.enabled = false;
await unmute;
track.mediaStreamTrack.enabled = false;
this.requireCurrent(binding, generation, signal);
});
this.forcedClosed = false;
track.mediaStreamTrack.enabled = true;
this.requireCurrent(binding, generation, signal);
} catch (error) {
disableCapture(binding);
if (this.generation === generation && this.getBinding()?.track === track) {
this.forcedClosed = true;
this.queueMute(binding);
}
throw error;
}
}

snapshot(): MediaGateDeviceState {
const binding = this.getBinding();
if (!binding) {
return {
captureActive: false,
trackPublished: false,
trackMuted: true,
};
}

const { track } = binding;
const captureActive =
!this.forcedClosed &&
track.mediaStreamTrack.enabled &&
track.mediaStreamTrack.readyState !== 'ended';
return {
captureActive,
trackPublished: true,
trackMuted: this.forcedClosed || track.isMuted || !track.mediaStreamTrack.enabled,
};
}

private requireCurrent(
binding: BrowserAudioGateBinding,
generation: number,
signal: AbortSignal
): void {
throwIfAborted(signal);
if (this.generation !== generation || this.getBinding()?.track !== binding.track) {
throw new DOMException('browser audio gate operation was superseded', 'AbortError');
}
}

private queueMute(binding: BrowserAudioGateBinding): void {
void this.enqueueSignaling(binding.track, async () => {
disableCapture(binding);
try {
await binding.track.mute();
} catch {
// Capture was disabled synchronously; LiveKit mute is best effort.
} finally {
disableCapture(binding);
}
});
}

private enqueueSignaling(
track: BrowserAudioGateTrack,
operation: () => Promise<void>
): Promise<void> {
const previous = this.signalingTails.get(track) ?? Promise.resolve();
const result = previous.then(operation, operation);
const settled = result.then(
() => undefined,
() => undefined
);
this.signalingTails.set(track, settled);
void settled.then(() => {
if (this.signalingTails.get(track) === settled) {
this.signalingTails.delete(track);
}
});
return result;
}
}

function disableCapture(binding: BrowserAudioGateBinding): void {
binding.track.mediaStreamTrack.enabled = false;
}

function throwIfAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw new DOMException('browser audio gate operation was aborted', 'AbortError');
}
}
113 changes: 113 additions & 0 deletions lib/browser-source-runtime-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
export type RuntimeSlot<Runtime> = {
current: Runtime | null;
};

export type RuntimeStartStage = <Value>(
operation: () => Value | PromiseLike<Value>
) => Promise<Value>;

export type AudioBindingRuntime = {
readonly audioEnabled: boolean;
readonly audioPublishPromise: Promise<void> | null;
};

export type AudioBindingReplacement = {
close(): void;
unpublish(): Promise<void>;
reconcile(): Promise<void>;
ensurePublished(): Promise<void>;
hasBinding(): boolean;
};

export class RuntimeStartCancelledError extends Error {
override readonly name = 'AbortError';

constructor(cause?: unknown) {
super('browser source runtime start was superseded');
this.cause = cause;
}
}

export function isCurrentRuntime<Runtime>(slot: RuntimeSlot<Runtime>, runtime: Runtime): boolean {
return slot.current === runtime;
}

export function detachCurrentRuntime<Runtime>(slot: RuntimeSlot<Runtime>): Runtime | null {
const runtime = slot.current;
slot.current = null;
return runtime;
}

export async function stopOwnedRuntime<Runtime>(
slot: RuntimeSlot<Runtime>,
runtime: Runtime,
stopRuntime: (runtime: Runtime) => Promise<void>
): Promise<void> {
if (slot.current === runtime) {
slot.current = null;
}
await stopRuntime(runtime);
}

export async function runOwnedRuntimeStart<Runtime>(
slot: RuntimeSlot<Runtime>,
runtime: Runtime,
stopRuntime: (runtime: Runtime) => Promise<void>,
startRuntime: (stage: RuntimeStartStage) => Promise<void>
): Promise<void> {
const assertOwned = () => {
if (!isCurrentRuntime(slot, runtime)) {
throw new RuntimeStartCancelledError();
}
};
const stage: RuntimeStartStage = async (operation) => {
assertOwned();
try {
const value = await operation();
assertOwned();
return value;
} catch (error) {
if (!isCurrentRuntime(slot, runtime)) {
throw new RuntimeStartCancelledError(error);
}
throw error;
}
};

try {
assertOwned();
await startRuntime(stage);
assertOwned();
} catch (error) {
try {
await stopOwnedRuntime(slot, runtime, stopRuntime);
} catch (stopError) {
if (isAbortError(error)) throw error;
throw stopError;
}
throw error;
}
}

export async function replaceRuntimeAudioBinding(
runtime: AudioBindingRuntime,
replacement: AudioBindingReplacement
): Promise<void> {
replacement.close();
await runtime.audioPublishPromise?.catch(() => undefined);
await replacement.unpublish();
if (!runtime.audioEnabled) return;

await replacement.reconcile();
if (replacement.hasBinding()) return;

await replacement.ensurePublished();
await replacement.reconcile();
}

function isAbortError(error: unknown): boolean {
return (
error instanceof RuntimeStartCancelledError ||
(typeof error === 'object' && error !== null && 'name' in error && error.name === 'AbortError')
);
}
Loading
Loading