fix: resolve remote video streams not displaying due to identity format mismatch

Server generates LiveKit participant identities as "user-{id}:{voiceJoinToken}"
but parseUserId regex required exact "user-{id}" (with $ anchor), returning 0
for all remote participants. This caused the userId > 0 guard in
handleTrackSubscribed to silently drop all remote video callbacks.

- Update parseUserId regex to accept both "user-{id}" and "user-{id}:{token}"
- Fix getRemoteVideoStream to iterate remoteParticipants instead of exact
  identity lookup (which also failed due to the token suffix)
- Add test cases for token-suffixed identities
- Fix pre-existing noUncheckedIndexedAccess TS errors in test files
This commit is contained in:
jevb
2026-04-01 17:05:57 +02:00
parent 6de2065c34
commit f4a6eb83f3
5 changed files with 319 additions and 9 deletions
@@ -48,9 +48,9 @@ const log = createLogger("livekitSession");
// --- Pure helpers (no instance state) ---
/** Parse userId from LiveKit participant identity "user-{id}". Returns 0 if unparseable. */
/** Parse userId from LiveKit participant identity "user-{id}" or "user-{id}:{token}". Returns 0 if unparseable. */
export function parseUserId(identity: string): number {
const match = identity.match(/^user-(\d+)$/);
const match = identity.match(/^user-(\d+)(?::|$)/);
if (match !== null && match[1] !== undefined) return parseInt(match[1], 10);
return 0;
}
+10 -5
View File
@@ -276,11 +276,16 @@ export function getRemoteVideoStream(
type: "camera" | "screenshare",
): MediaStream | null {
if (room === null) return null;
const participant = room.getParticipantByIdentity(`user-${userId}`);
if (participant === undefined) return null;
if (participant === room.localParticipant) return null;
const source = type === "screenshare" ? Track.Source.ScreenShare : Track.Source.Camera;
const pub = participant.getTrackPublication(source);
if (pub?.track?.mediaStreamTrack) return new MediaStream([pub.track.mediaStreamTrack]);
// Iterate remote participants — identity may include a ":token" suffix
// (e.g. "user-42:abc123") so exact getParticipantByIdentity won't match.
for (const participant of room.remoteParticipants.values()) {
const match = participant.identity.match(/^user-(\d+)(?::|$)/);
if (match !== null && parseInt(match[1]!, 10) === userId) {
const pub = participant.getTrackPublication(source);
if (pub?.track?.mediaStreamTrack) return new MediaStream([pub.track.mediaStreamTrack]);
return null;
}
}
return null;
}
@@ -203,6 +203,18 @@ describe("parseUserId", () => {
it("parses single digit user IDs", () => {
expect(parseUserId("user-1")).toBe(1);
});
it("parses identity with voiceJoinToken suffix", () => {
expect(parseUserId("user-42:abc123def")).toBe(42);
});
it("parses identity with long token suffix", () => {
expect(parseUserId("user-999:a1b2c3d4-e5f6-7890-abcd-ef1234567890")).toBe(999);
});
it("returns 0 for colon with no token", () => {
expect(parseUserId("user-:token")).toBe(0);
});
});
describe("LiveKitSession", () => {
@@ -1607,7 +1619,7 @@ describe("LiveKitSession", () => {
firstConnect.resolve(undefined);
await firstJoin;
const lastCall = mockRoom.connect.mock.calls[mockRoom.connect.mock.calls.length - 1];
const lastCall = mockRoom.connect.mock.calls[mockRoom.connect.mock.calls.length - 1]!;
expect(lastCall[1]).toBe("token-3");
});
});
@@ -25,7 +25,40 @@ import {
/** Minimal MediaStream stub for testing. */
function fakeStream(): MediaStream {
return { getTracks: () => [] } as unknown as MediaStream;
return { getTracks: () => [], getVideoTracks: () => [] } as unknown as MediaStream;
}
/**
* MediaStream stub with a controllable video track.
* Allows testing track lifecycle (ended/mute events).
*/
function fakeStreamWithTrack(): {
stream: MediaStream;
track: { listeners: Record<string, Array<() => void>>; dispatchEvent(type: string): void };
} {
const listeners: Record<string, Array<() => void>> = {};
const track = {
listeners,
id: `track-${Math.random()}`,
addEventListener(type: string, fn: () => void) {
(listeners[type] ??= []).push(fn);
},
removeEventListener(type: string, fn: () => void) {
const arr = listeners[type];
if (arr) {
const idx = arr.indexOf(fn);
if (idx >= 0) arr.splice(idx, 1);
}
},
dispatchEvent(type: string) {
for (const fn of listeners[type] ?? []) fn();
},
};
const stream = {
getTracks: () => [track],
getVideoTracks: () => [track],
} as unknown as MediaStream;
return { stream, track };
}
// ---------------------------------------------------------------------------
@@ -200,6 +233,102 @@ describe("VideoGrid", () => {
expect(grid.hasStreams()).toBe(false);
});
// -----------------------------------------------------------------------
// Autoplay / .play() tests (Bug fix: black window in WebView2)
// -----------------------------------------------------------------------
describe("video autoplay", () => {
it("addStream calls .play() on the video element", () => {
const playMock = vi.fn().mockResolvedValue(undefined);
const origCreate = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation(
(tag: string, opts?: ElementCreationOptions) => {
const el = origCreate(tag, opts);
if (tag === "video") {
(el as HTMLVideoElement).play = playMock;
}
return el;
},
);
grid.addStream(1, "Alice", fakeStream());
expect(playMock).toHaveBeenCalledTimes(1);
vi.restoreAllMocks();
});
it("addStream calls .play() when replacing srcObject on existing tile", () => {
const playMock = vi.fn().mockResolvedValue(undefined);
const origCreate = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation(
(tag: string, opts?: ElementCreationOptions) => {
const el = origCreate(tag, opts);
if (tag === "video") {
(el as HTMLVideoElement).play = playMock;
}
return el;
},
);
const { stream: s1 } = fakeStreamWithTrack();
const { stream: s2 } = fakeStreamWithTrack();
grid.addStream(1, "Alice", s1);
playMock.mockClear();
// Different tracks → srcObject replaced → should call play again
grid.addStream(1, "Alice", s2);
expect(playMock).toHaveBeenCalledTimes(1);
vi.restoreAllMocks();
});
});
// -----------------------------------------------------------------------
// Track lifecycle tests (Bug fix: stale black tiles)
// -----------------------------------------------------------------------
describe("track lifecycle", () => {
it("removes tile when video track fires 'ended' event", () => {
const { stream, track } = fakeStreamWithTrack();
grid.addStream(1, "Alice", stream);
expect(grid.hasStreams()).toBe(true);
track.dispatchEvent("ended");
expect(grid.hasStreams()).toBe(false);
});
it("removes tile when video track fires 'mute' event", () => {
const { stream, track } = fakeStreamWithTrack();
grid.addStream(1, "Alice", stream);
expect(grid.hasStreams()).toBe(true);
track.dispatchEvent("mute");
expect(grid.hasStreams()).toBe(false);
});
it("cleans up track listeners when tile is removed via removeStream", () => {
const { stream, track } = fakeStreamWithTrack();
grid.addStream(1, "Alice", stream);
grid.removeStream(1);
// Dispatching ended after removal should not throw or cause issues
expect(() => track.dispatchEvent("ended")).not.toThrow();
expect(grid.hasStreams()).toBe(false);
});
it("cleans up track listeners on destroy", () => {
const { stream, track } = fakeStreamWithTrack();
grid.addStream(1, "Alice", stream);
grid.destroy!();
// Verify listeners were removed
expect(track.listeners["ended"]?.length ?? 0).toBe(0);
expect(track.listeners["mute"]?.length ?? 0).toBe(0);
});
});
// -----------------------------------------------------------------------
// TileConfig / overlay / mute button tests (Spec 1)
// -----------------------------------------------------------------------
@@ -1,4 +1,21 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ---------------------------------------------------------------------------
// Mocks — must be declared before importing VoiceChannel
// ---------------------------------------------------------------------------
const mockAttachStreamPreview = vi.fn();
const mockAttachScrollCollapse = vi.fn();
vi.mock("@lib/streamPreview", () => ({
attachStreamPreview: (...args: unknown[]) => mockAttachStreamPreview(...args),
attachScrollCollapse: (...args: unknown[]) => mockAttachScrollCollapse(...args),
}));
// ---------------------------------------------------------------------------
// Imports
// ---------------------------------------------------------------------------
import { createVoiceChannel } from "../../src/components/VoiceChannel";
import { voiceStore } from "../../src/stores/voice.store";
import { membersStore } from "../../src/stores/members.store";
@@ -61,6 +78,8 @@ describe("VoiceChannel", () => {
beforeEach(() => {
resetStores();
mockAttachStreamPreview.mockClear();
mockAttachScrollCollapse.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
});
@@ -737,6 +756,151 @@ describe("VoiceChannel", () => {
result.destroy();
});
// ── Stream preview attachment ──
describe("stream preview", () => {
it("attaches stream preview for remote user with active camera", () => {
authStore.setState(() => ({
token: "tok",
user: { id: 99, username: "Me", avatar: null, role: "member" },
serverName: null,
motd: null,
isAuthenticated: true,
}));
addMember(10, "Alice");
setVoiceUsers(1, [
{
userId: 10,
username: "Alice",
muted: false,
deafened: false,
speaking: false,
camera: true,
screenshare: false,
},
]);
const onWatch = vi.fn();
const result = createVoiceChannel({
channelId: 1,
channelName: "Voice Lobby",
onJoin: vi.fn(),
onClickWatch: onWatch,
});
container.appendChild(result.element);
expect(mockAttachStreamPreview).toHaveBeenCalledTimes(1);
// Verify key args: row element, userId, username, screenshare, camera
const args = mockAttachStreamPreview.mock.calls[0]!;
expect(args[1]).toBe(10); // userId
expect(args[2]).toBe("Alice"); // username
expect(args[3]).toBe(false); // hasScreenshare
expect(args[4]).toBe(true); // hasCamera
result.destroy();
});
it("does not attach stream preview for own user", () => {
authStore.setState(() => ({
token: "tok",
user: { id: 10, username: "Alice", avatar: null, role: "member" },
serverName: null,
motd: null,
isAuthenticated: true,
}));
addMember(10, "Alice");
setVoiceUsers(1, [
{
userId: 10,
username: "Alice",
muted: false,
deafened: false,
speaking: false,
camera: true,
screenshare: false,
},
]);
const result = createVoiceChannel({
channelId: 1,
channelName: "Voice Lobby",
onJoin: vi.fn(),
onClickWatch: vi.fn(),
});
container.appendChild(result.element);
expect(mockAttachStreamPreview).not.toHaveBeenCalled();
result.destroy();
});
it("does not attach stream preview when user has no camera or screenshare", () => {
authStore.setState(() => ({
token: "tok",
user: { id: 99, username: "Me", avatar: null, role: "member" },
serverName: null,
motd: null,
isAuthenticated: true,
}));
addMember(10, "Alice");
setVoiceUsers(1, [
{
userId: 10,
username: "Alice",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
},
]);
const result = createVoiceChannel({
channelId: 1,
channelName: "Voice Lobby",
onJoin: vi.fn(),
onClickWatch: vi.fn(),
});
container.appendChild(result.element);
expect(mockAttachStreamPreview).not.toHaveBeenCalled();
result.destroy();
});
it("attaches scroll collapse on voice-users-list container", () => {
setVoiceUsers(1, [
{
userId: 10,
username: "Alice",
muted: false,
deafened: false,
speaking: false,
camera: true,
screenshare: false,
},
]);
const result = createVoiceChannel({
channelId: 1,
channelName: "Voice Lobby",
onJoin: vi.fn(),
onClickWatch: vi.fn(),
});
container.appendChild(result.element);
expect(mockAttachScrollCollapse).toHaveBeenCalledTimes(1);
const args = mockAttachScrollCollapse.mock.calls[0]!;
expect(args[0]).toBeInstanceOf(HTMLElement);
expect((args[0] as HTMLElement).classList.contains("voice-users-list")).toBe(true);
result.destroy();
});
});
// ── User both muted and deafened ──
it("shows deafened icon when user is both muted and deafened", () => {