mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user