Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
438d09cde9 | ||
|
|
0534fa393c | ||
|
|
e12b3e5979 | ||
|
|
657271163f | ||
|
|
60398b0a7d | ||
|
|
2c8d20ba82 |
@@ -37,7 +37,7 @@ checked against cinny's `lotus` branch.
|
|||||||
| ---------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
| ---------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||||
| `io.lotus.call_state` | call → host | in-call per-participant speaking / mute state. NOTE: matrix-widget-api `transport.send` is request/response — the host MUST register a handler that replies/acks each one (cinny's `listenAction` does, replying `{}`). If the host has no handler, ClientWidgetApi immediately error-replies "unsupported from-widget action", so the data is silently dropped and each throttled send rejects (caught) — functional miss + log churn, not a hang. | yes |
|
| `io.lotus.call_state` | call → host | in-call per-participant speaking / mute state. NOTE: matrix-widget-api `transport.send` is request/response — the host MUST register a handler that replies/acks each one (cinny's `listenAction` does, replying `{}`). If the host has no handler, ClientWidgetApi immediately error-replies "unsupported from-widget action", so the data is silently dropped and each throttled send rejects (caught) — functional miss + log churn, not a hang. | yes |
|
||||||
| `io.lotus.focus_participant` | host → call | pin/spotlight (or clear, with userId=null) a participant. | yes |
|
| `io.lotus.focus_participant` | host → call | pin/spotlight (or clear, with userId=null) a participant. | yes |
|
||||||
| `io.lotus.inject_audio` | host → call | play an audio clip into the call as a separate published track. | yes |
|
| `io.lotus.inject_audio` | host → call | play an audio clip into the call: as a separate published track, or mixed into the microphone where the token only allows listed sources (rooms with a call policy, #47). Replies `{ played: false, reason: "muted" \| "not_permitted" }` when it can't. | yes |
|
||||||
| `io.lotus.set_quality` | host → call | set audio/screenshare encoding quality (bitrate/framerate). | yes |
|
| `io.lotus.set_quality` | host → call | set audio/screenshare encoding quality (bitrate/framerate). | yes |
|
||||||
| `io.lotus.decorations` | host → call | per-user avatar-decoration image URLs for in-call tiles. | yes |
|
| `io.lotus.decorations` | host → call | per-user avatar-decoration image URLs for in-call tiles. | yes |
|
||||||
| `io.lotus.set_deafen` | host → call | deafen remote audio (and optionally mute screenshare audio). | yes |
|
| `io.lotus.set_deafen` | host → call | deafen remote audio (and optionally mute screenshare audio). | yes |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@lotusguild/element-call-embedded",
|
"name": "@lotusguild/element-call-embedded",
|
||||||
"version": "0.25.0-lotus.26",
|
"version": "0.25.0-lotus.28",
|
||||||
"files": [
|
"files": [
|
||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE-AGPL-3.0",
|
"LICENSE-AGPL-3.0",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
|||||||
import {
|
import {
|
||||||
canPublishSoundboard,
|
canPublishSoundboard,
|
||||||
MAX_INJECT_BYTES,
|
MAX_INJECT_BYTES,
|
||||||
|
soundboardMode,
|
||||||
parseInjectSource,
|
parseInjectSource,
|
||||||
startLotusAudioInject,
|
startLotusAudioInject,
|
||||||
} from "./lotusAudioInject";
|
} from "./lotusAudioInject";
|
||||||
@@ -94,6 +95,10 @@ class FakeAudioContext {
|
|||||||
connect: vi.fn((n: unknown) => n),
|
connect: vi.fn((n: unknown) => n),
|
||||||
disconnect: vi.fn(),
|
disconnect: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
public createMediaStreamSource = vi.fn(() => ({
|
||||||
|
connect: vi.fn((n: unknown) => n),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
}));
|
||||||
public createBufferSource = vi.fn(() => ({
|
public createBufferSource = vi.fn(() => ({
|
||||||
buffer: undefined as AudioBuffer | undefined,
|
buffer: undefined as AudioBuffer | undefined,
|
||||||
connect: vi.fn((n: unknown) => n),
|
connect: vi.fn((n: unknown) => n),
|
||||||
@@ -116,6 +121,12 @@ beforeEach(() => {
|
|||||||
// leave a real timer pending.
|
// leave a real timer pending.
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.stubGlobal("AudioContext", FakeAudioContext);
|
vi.stubGlobal("AudioContext", FakeAudioContext);
|
||||||
|
vi.stubGlobal(
|
||||||
|
"MediaStream",
|
||||||
|
class {
|
||||||
|
public constructor(public tracks: unknown[]) {}
|
||||||
|
},
|
||||||
|
);
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn().mockResolvedValue({
|
vi.fn().mockResolvedValue({
|
||||||
@@ -273,3 +284,127 @@ test("canPublishSoundboard: refused by a microphone-only grant (EC #47)", () =>
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(canPublishSoundboard({ canPublish: false })).toBe(false);
|
expect(canPublishSoundboard({ canPublish: false })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("soundboardMode: own track when unrestricted, the mic under a call policy (EC #47)", () => {
|
||||||
|
expect(soundboardMode(undefined)).toBe("track");
|
||||||
|
expect(soundboardMode({ canPublish: true, canPublishSources: [] })).toBe(
|
||||||
|
"track",
|
||||||
|
);
|
||||||
|
expect(soundboardMode({ canPublish: true, canPublishSources: [2, 0] })).toBe(
|
||||||
|
"track",
|
||||||
|
);
|
||||||
|
expect(soundboardMode({ canPublish: true, canPublishSources: [2] })).toBe(
|
||||||
|
"mic",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
soundboardMode({ canPublish: true, canPublishSources: [2, 3, 4] }),
|
||||||
|
).toBe("mic");
|
||||||
|
expect(
|
||||||
|
soundboardMode({ canPublish: true, canPublishSources: [3] }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(soundboardMode({ canPublish: false })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A room whose token only allows the microphone, with a published mic track. */
|
||||||
|
function makePolicyRoom(sources: number[]): {
|
||||||
|
room: unknown;
|
||||||
|
sender: { track: MediaStreamTrack; replaceTrack: ReturnType<typeof vi.fn> };
|
||||||
|
original: MediaStreamTrack;
|
||||||
|
micTrack: EventEmitter;
|
||||||
|
publishTrack: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
const original = makeTrack();
|
||||||
|
const sender = {
|
||||||
|
track: original,
|
||||||
|
replaceTrack: vi.fn(async (t: MediaStreamTrack) => {
|
||||||
|
await Promise.resolve();
|
||||||
|
sender.track = t;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const micTrack = Object.assign(new EventEmitter(), { sender });
|
||||||
|
const publishTrack = vi.fn();
|
||||||
|
const room = {
|
||||||
|
localParticipant: {
|
||||||
|
isMicrophoneEnabled: true,
|
||||||
|
permissions: { canPublish: true, canPublishSources: sources },
|
||||||
|
publishTrack,
|
||||||
|
unpublishTrack: vi.fn(),
|
||||||
|
getTrackPublication: (): unknown => ({ track: micTrack }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { room, sender, original, micTrack, publishTrack };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("[EC #47] under a mic-only grant the clip is mixed into the microphone, then the mic is restored", async () => {
|
||||||
|
const { room, sender, original, publishTrack } = makePolicyRoom([2]);
|
||||||
|
const stop = startLotusAudioInject(mockVm([room]));
|
||||||
|
|
||||||
|
send({ url: "https://example.com/clip.mp3" });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(reply).toHaveBeenCalledWith(expect.anything(), {});
|
||||||
|
expect(publishTrack).not.toHaveBeenCalled();
|
||||||
|
expect(sender.replaceTrack).toHaveBeenCalledTimes(1);
|
||||||
|
const mixed = sender.track;
|
||||||
|
expect(mixed).not.toBe(original);
|
||||||
|
const source = contextInstances[0].createBufferSource.mock.results[0].value;
|
||||||
|
expect(source.start).toHaveBeenCalled();
|
||||||
|
|
||||||
|
source.onended();
|
||||||
|
await flush();
|
||||||
|
expect(sender.replaceTrack).toHaveBeenLastCalledWith(original);
|
||||||
|
expect(sender.track).toBe(original);
|
||||||
|
expect(mixed.stop).toHaveBeenCalled();
|
||||||
|
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("[EC #47] muting mid-clip stops the clip and restores the microphone", async () => {
|
||||||
|
const { room, sender, original, micTrack } = makePolicyRoom([2]);
|
||||||
|
const stop = startLotusAudioInject(mockVm([room]));
|
||||||
|
|
||||||
|
send({ url: "https://example.com/clip.mp3" });
|
||||||
|
await flush();
|
||||||
|
const source = contextInstances[0].createBufferSource.mock.results[0].value;
|
||||||
|
|
||||||
|
micTrack.emit("muted");
|
||||||
|
await flush();
|
||||||
|
expect(source.stop).toHaveBeenCalled();
|
||||||
|
expect(sender.track).toBe(original);
|
||||||
|
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("[EC #47] a track LiveKit swapped in meanwhile is not overwritten on restore", async () => {
|
||||||
|
const { room, sender, original } = makePolicyRoom([2]);
|
||||||
|
const stop = startLotusAudioInject(mockVm([room]));
|
||||||
|
|
||||||
|
send({ url: "https://example.com/clip.mp3" });
|
||||||
|
await flush();
|
||||||
|
const newer = makeTrack();
|
||||||
|
sender.track = newer; // e.g. a device switch while the clip plays
|
||||||
|
const source = contextInstances[0].createBufferSource.mock.results[0].value;
|
||||||
|
source.onended();
|
||||||
|
await flush();
|
||||||
|
expect(sender.track).toBe(newer);
|
||||||
|
expect(sender.replaceTrack).not.toHaveBeenCalledWith(original);
|
||||||
|
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("[EC #47] no microphone grant at all is still refused up front", async () => {
|
||||||
|
const { room, publishTrack, sender } = makePolicyRoom([3]);
|
||||||
|
const stop = startLotusAudioInject(mockVm([room]));
|
||||||
|
|
||||||
|
send({ url: "https://example.com/clip.mp3" });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(reply).toHaveBeenCalledWith(expect.anything(), {
|
||||||
|
played: false,
|
||||||
|
reason: "not_permitted",
|
||||||
|
});
|
||||||
|
expect(publishTrack).not.toHaveBeenCalled();
|
||||||
|
expect(sender.replaceTrack).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type Room as LivekitRoom, Track } from "livekit-client";
|
import {
|
||||||
|
type LocalTrack,
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
Track,
|
||||||
|
TrackEvent,
|
||||||
|
} from "livekit-client";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||||
|
|
||||||
@@ -52,6 +57,10 @@ function acquireSharedAudio(): {
|
|||||||
* that was impossible against the prebuilt EC bundle (LiveKit's
|
* that was impossible against the prebuilt EC bundle (LiveKit's
|
||||||
* LocalParticipant lived in EC's module scope).
|
* LocalParticipant lived in EC's module scope).
|
||||||
*
|
*
|
||||||
|
* [EC #47] Exception: rooms with a call policy issue tokens that only allow
|
||||||
|
* listed sources, which refuse that separate track. There the clip is mixed
|
||||||
|
* into the published microphone for its duration (`playIntoMicrophone`).
|
||||||
|
*
|
||||||
* Action data: `{ url?: string, audio?: ArrayBuffer, volume?: number }`.
|
* Action data: `{ url?: string, audio?: ArrayBuffer, volume?: number }`.
|
||||||
* `audio` is the clip's bytes (cinny #43: a host `blob:` URL can't be fetched
|
* `audio` is the clip's bytes (cinny #43: a host `blob:` URL can't be fetched
|
||||||
* once EC is served from its own origin, so the host sends the bytes and they
|
* once EC is served from its own origin, so the host sends the bytes and they
|
||||||
@@ -76,6 +85,25 @@ export function canPublishSoundboard(
|
|||||||
return sources.length === 0 || sources.includes(0);
|
return sources.length === 0 || sources.includes(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure: how a clip can reach the call under these permissions (EC #47).
|
||||||
|
* - `"track"`: as its own Unknown-source track (unrestricted rooms).
|
||||||
|
* - `"mic"`: mixed into the published microphone track. Rooms with a call
|
||||||
|
* policy grant only listed sources (microphone, maybe screenshare), which
|
||||||
|
* refuse the separate track, but the mic is allowed.
|
||||||
|
* - `null`: neither (no publishing at all).
|
||||||
|
*/
|
||||||
|
export function soundboardMode(
|
||||||
|
perm:
|
||||||
|
| { canPublish?: boolean; canPublishSources?: readonly number[] }
|
||||||
|
| undefined,
|
||||||
|
): "track" | "mic" | null {
|
||||||
|
if (canPublishSoundboard(perm)) return "track";
|
||||||
|
if (perm?.canPublish === false) return null;
|
||||||
|
// TrackSource.MICROPHONE = 2
|
||||||
|
return perm?.canPublishSources?.includes(2) ? "mic" : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function startLotusAudioInject(vm: CallViewModel): () => void {
|
export function startLotusAudioInject(vm: CallViewModel): () => void {
|
||||||
const w = widget;
|
const w = widget;
|
||||||
if (!w) return () => undefined;
|
if (!w) return () => undefined;
|
||||||
@@ -132,21 +160,24 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// [EC #47] Clips publish as an Unknown-source track. Audio-only rooms get
|
// [EC #47] Clips publish as an Unknown-source track, which rooms with a
|
||||||
// a token limited to the microphone source, where LiveKit refuses that
|
// call policy refuse (their tokens list allowed sources). There the clip
|
||||||
// publish *after* we replied; say so up front instead of failing silently.
|
// is mixed into the published microphone instead; only when neither is
|
||||||
if (
|
// possible say so up front instead of failing silently.
|
||||||
rooms.some((r) => !canPublishSoundboard(r.localParticipant.permissions))
|
const modes = rooms.map((r) =>
|
||||||
) {
|
soundboardMode(r.localParticipant.permissions),
|
||||||
|
);
|
||||||
|
if (modes.some((m) => m === null)) {
|
||||||
w.api.transport.reply(ev.detail, {
|
w.api.transport.reply(ev.detail, {
|
||||||
played: false,
|
played: false,
|
||||||
reason: "not_permitted",
|
reason: "not_permitted",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const mode = modes.includes("mic") ? "mic" : "track";
|
||||||
|
|
||||||
w.api.transport.reply(ev.detail, {});
|
w.api.transport.reply(ev.detail, {});
|
||||||
void playInjectedClip(clip, volume, rooms, activeClips).catch((e) =>
|
void playInjectedClip(clip, volume, rooms, activeClips, mode).catch((e) =>
|
||||||
logger.warn("[lotus] inject_audio failed", e),
|
logger.warn("[lotus] inject_audio failed", e),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -211,6 +242,7 @@ async function playInjectedClip(
|
|||||||
volume: number,
|
volume: number,
|
||||||
rooms: LivekitRoom[],
|
rooms: LivekitRoom[],
|
||||||
activeClips: Set<() => void>,
|
activeClips: Set<() => void>,
|
||||||
|
mode: "track" | "mic" = "track",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (rooms.length === 0) {
|
if (rooms.length === 0) {
|
||||||
logger.warn("[lotus] inject_audio: no connected rooms");
|
logger.warn("[lotus] inject_audio: no connected rooms");
|
||||||
@@ -295,8 +327,23 @@ async function playInjectedClip(
|
|||||||
gain.gain.value = volume;
|
gain.gain.value = volume;
|
||||||
const source = ctx.createBufferSource();
|
const source = ctx.createBufferSource();
|
||||||
source.buffer = buffer;
|
source.buffer = buffer;
|
||||||
source.connect(gain).connect(dest);
|
source.connect(gain);
|
||||||
|
|
||||||
|
if (mode === "mic") {
|
||||||
|
playIntoMicrophone(
|
||||||
|
ctx,
|
||||||
|
source,
|
||||||
|
gain,
|
||||||
|
buffer,
|
||||||
|
rooms,
|
||||||
|
activeClips,
|
||||||
|
placeholder,
|
||||||
|
() => aborted,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gain.connect(dest);
|
||||||
const mst = dest.stream.getAudioTracks()[0];
|
const mst = dest.stream.getAudioTracks()[0];
|
||||||
if (!mst) {
|
if (!mst) {
|
||||||
source.disconnect();
|
source.disconnect();
|
||||||
@@ -375,3 +422,114 @@ async function playInjectedClip(
|
|||||||
|
|
||||||
source.start();
|
source.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [EC #47] Mix the clip into the published microphone for its duration. For
|
||||||
|
* every room, the track currently going out on the mic's RTCRtpSender (the
|
||||||
|
* denoised one, when noise suppression is on) and the clip are mixed in a
|
||||||
|
* MediaStreamAudioDestinationNode, and the sender switches to the mix with
|
||||||
|
* `replaceTrack`. The server still sees one ordinary microphone track, which
|
||||||
|
* rooms with a call policy allow. When the clip ends the original track goes
|
||||||
|
* back. Muting mid-clip stops the clip (#13: muted means silent), and if
|
||||||
|
* LiveKit swapped its own track in meanwhile (device change, denoise toggle)
|
||||||
|
* that one is left alone.
|
||||||
|
*/
|
||||||
|
function playIntoMicrophone(
|
||||||
|
ctx: AudioContext,
|
||||||
|
source: AudioBufferSourceNode,
|
||||||
|
gain: GainNode,
|
||||||
|
buffer: AudioBuffer,
|
||||||
|
rooms: LivekitRoom[],
|
||||||
|
activeClips: Set<() => void>,
|
||||||
|
placeholder: () => void,
|
||||||
|
isAborted: () => boolean,
|
||||||
|
): void {
|
||||||
|
type Mix = {
|
||||||
|
sender: RTCRtpSender;
|
||||||
|
original: MediaStreamTrack;
|
||||||
|
mixed: MediaStreamTrack;
|
||||||
|
mic: MediaStreamAudioSourceNode;
|
||||||
|
dest: MediaStreamAudioDestinationNode;
|
||||||
|
track: LocalTrack;
|
||||||
|
};
|
||||||
|
const mixes: Mix[] = [];
|
||||||
|
let cleanedUp = false;
|
||||||
|
const cleanup = (): void => {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
|
activeClips.delete(cleanup);
|
||||||
|
activeClips.delete(placeholder);
|
||||||
|
try {
|
||||||
|
source.stop();
|
||||||
|
} catch {
|
||||||
|
/* not started or already stopped */
|
||||||
|
}
|
||||||
|
source.disconnect();
|
||||||
|
gain.disconnect();
|
||||||
|
for (const m of mixes) {
|
||||||
|
m.track.off(TrackEvent.Muted, cleanup);
|
||||||
|
m.mic.disconnect();
|
||||||
|
// Only undo our own swap: LiveKit may have replaced the track since.
|
||||||
|
if (m.sender.track === m.mixed)
|
||||||
|
void m.sender.replaceTrack(m.original).catch(() => undefined);
|
||||||
|
m.mixed.stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
const track = room.localParticipant.getTrackPublication(
|
||||||
|
Track.Source.Microphone,
|
||||||
|
)?.track;
|
||||||
|
const sender = track?.sender;
|
||||||
|
const original = sender?.track;
|
||||||
|
if (!track || !sender || !original) continue;
|
||||||
|
const dest = ctx.createMediaStreamDestination();
|
||||||
|
const mic = ctx.createMediaStreamSource(new MediaStream([original]));
|
||||||
|
mic.connect(dest);
|
||||||
|
gain.connect(dest);
|
||||||
|
const mixed = dest.stream.getAudioTracks()[0];
|
||||||
|
if (!mixed) {
|
||||||
|
mic.disconnect();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mixes.push({ sender, original, mixed, mic, dest, track });
|
||||||
|
track.on(TrackEvent.Muted, cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
activeClips.delete(placeholder);
|
||||||
|
activeClips.add(cleanup);
|
||||||
|
if (mixes.length === 0) {
|
||||||
|
logger.warn("[lotus] inject_audio: no published microphone to mix into");
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAborted()) {
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch every sender to its mix, then start the clip once they're live so
|
||||||
|
// its start isn't clipped.
|
||||||
|
void Promise.all(
|
||||||
|
mixes.map(async (m) =>
|
||||||
|
m.sender.replaceTrack(m.mixed).catch((e) => {
|
||||||
|
logger.warn("[lotus] inject_audio: replaceTrack failed", e);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).then(() => {
|
||||||
|
if (cleanedUp || isAborted()) {
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
source.onended = cleanup;
|
||||||
|
const durationMs = Number.isFinite(buffer.duration)
|
||||||
|
? buffer.duration * 1000 + 500
|
||||||
|
: MAX_CLIP_MS;
|
||||||
|
const guard = setTimeout(
|
||||||
|
cleanup,
|
||||||
|
Math.min(MAX_CLIP_MS, Math.max(0, durationMs)),
|
||||||
|
);
|
||||||
|
source.addEventListener("ended", () => clearTimeout(guard));
|
||||||
|
source.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user