feat: sidebar stream preview + screenshare focus fix

- Add hover/focus preview for remote voice users' camera/screenshare
  streams in the voice channel sidebar. Preview reuses already-subscribed
  LiveKit tracks (zero bandwidth cost). Full-width 16:9 preview with
  neon border-glow treatment matching DESIGN.md.

- Fix screenshare focus bug: clicking a screensharing user now correctly
  focuses the screenshare tile (userId + SCREENSHARE_TILE_ID_OFFSET)
  instead of the camera tile.

- Extract SCREENSHARE_TILE_ID_OFFSET to shared lib/constants.ts (was
  duplicated in VideoModeController.ts and MainPage.ts).

- New lib/streamPreview.ts module: attachStreamPreview() with 300ms
  debounce, track renegotiation detection, autoplay failure handling,
  scroll collapse, keyboard accessibility (focusin/focusout), ARIA
  labels, and full AbortSignal cleanup.

- Placeholder shows "Join to preview" with click-to-join behavior.
  Live video preview is also clickable to watch the stream.

- 22 new tests (16 stream-preview + 6 channel-sidebar).
This commit is contained in:
jevb
2026-03-30 19:12:36 +02:00
parent e4bb54405b
commit 5d0af7c03e
10 changed files with 879 additions and 4 deletions
@@ -28,6 +28,8 @@ import {
} from "@stores/ui.store";
import { voiceStore, getChannelVoiceUsers } from "@stores/voice.store";
import { setUserVolume, getUserVolume } from "@lib/livekitSession";
import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants";
import { attachStreamPreview, attachScrollCollapse } from "@lib/streamPreview";
// ---------------------------------------------------------------------------
// Per-user volume context menu (right-click on voice user row)
@@ -310,13 +312,39 @@ function renderVoiceChannelItem(
// Don't trigger if the right-click menu is open
if (e.button !== 0) return;
e.stopPropagation();
onWatchStream(user.userId);
const tileId = user.screenshare
? user.userId + SCREENSHARE_TILE_ID_OFFSET
: user.userId;
onWatchStream(tileId);
}, { signal });
row.style.cursor = "pointer";
}
// Hover/focus preview for remote users with video
if ((currentUser === null || currentUser.id !== user.userId)
&& (user.camera || user.screenshare)) {
const tileId = user.screenshare
? user.userId + SCREENSHARE_TILE_ID_OFFSET
: user.userId;
attachStreamPreview(
row,
user.userId,
user.username || "Unknown",
user.screenshare,
user.camera,
signal,
() => {
// Placeholder click: join voice channel and watch stream
onVoiceJoin(channel.id);
if (onWatchStream !== undefined) onWatchStream(tileId);
},
onWatchStream !== undefined ? () => onWatchStream(tileId) : undefined,
);
}
usersContainer.appendChild(row);
}
attachScrollCollapse(usersContainer, signal);
wrapper.appendChild(usersContainer);
}
+2
View File
@@ -0,0 +1,2 @@
/** Offset added to userId to produce a unique tile ID for screenshare tiles in the video grid. */
export const SCREENSHARE_TILE_ID_OFFSET = 1_000_000;
@@ -996,6 +996,19 @@ export class LiveKitSession {
return null;
}
/** Get a remote participant's video MediaStream by userId and track type. Returns null if not available. */
getRemoteVideoStream(userId: number, type: "camera" | "screenshare"): MediaStream | null {
if (this.room === null) return null;
const participant = this.room.getParticipantByIdentity(`user-${userId}`);
if (participant === undefined) return null;
// Self-guard: don't return local participant's stream via this method
if (participant === this.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]);
return null;
}
getRoom(): Room | null {
return this.room;
}
@@ -1161,6 +1174,7 @@ export const setVoiceSensitivity = session.setVoiceSensitivity.bind(session);
export const reapplyAudioProcessing = session.reapplyAudioProcessing.bind(session);
export const getLocalCameraStream = session.getLocalCameraStream.bind(session);
export const getLocalScreenshareStream = session.getLocalScreenshareStream.bind(session);
export const getRemoteVideoStream = session.getRemoteVideoStream.bind(session);
export const getSessionDebugInfo = session.getSessionDebugInfo.bind(session);
export const setScreenshareAudioVolume = session.setScreenshareAudioVolume.bind(session);
export const muteScreenshareAudio = session.muteScreenshareAudio.bind(session);
@@ -0,0 +1,283 @@
/**
* Stream preview — hover/focus to see a live video preview of a remote
* participant's camera or screenshare in the voice channel sidebar.
*
* Lifecycle:
* mouseenter/focusin → 300ms debounce → create <video> or placeholder
* mouseleave/focusout/scroll → animate collapse → remove DOM → cleanup
*
* All timers and listeners are cleaned up via AbortSignal on sidebar teardown.
*/
import { createElement } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { getRemoteVideoStream } from "@lib/livekitSession";
/** Internal state tracked per voice-user-item row for cleanup. */
interface PreviewState {
readonly debounce: number;
animation: number;
trackCleanup: (() => void) | null;
}
const previewTimers = new WeakMap<HTMLElement, PreviewState>();
/** Height the preview expands to. Set dynamically after DOM insertion. */
/** Debounce delay before showing the preview. */
const DEBOUNCE_MS = 300;
function clearPreviewState(row: HTMLElement): void {
const state = previewTimers.get(row);
if (state === undefined) return;
clearTimeout(state.debounce);
clearTimeout(state.animation);
if (state.trackCleanup !== null) state.trackCleanup();
previewTimers.delete(row);
}
function removePreviewDom(row: HTMLElement): void {
// Preview is inserted as sibling after the row, not inside it
const existing = row.nextElementSibling;
if (existing !== null && existing.classList.contains("vu-preview")) {
const video = existing.querySelector("video");
if (video !== null) video.srcObject = null;
existing.remove();
}
}
function showPreview(
row: HTMLElement,
userId: number,
username: string,
hasScreenshare: boolean,
hasCamera: boolean,
onClickJoin?: () => void,
onClickWatch?: () => void,
): void {
if (!document.contains(row)) return;
// Try screenshare first, then camera
let stream: MediaStream | null = null;
let isScreen = false;
if (hasScreenshare) {
stream = getRemoteVideoStream(userId, "screenshare");
if (stream !== null) isScreen = true;
}
if (stream === null && hasCamera) {
stream = getRemoteVideoStream(userId, "camera");
isScreen = false;
}
const previewDiv = createElement("div", { class: "vu-preview" });
if (stream !== null) {
const video = document.createElement("video");
video.autoplay = true;
video.playsInline = true;
video.muted = true;
video.className = isScreen ? "preview-screen" : "preview-camera";
video.setAttribute("aria-label", `Stream preview for ${username}`);
video.srcObject = stream;
// Handle autoplay failure — swap to placeholder
video.play().catch(() => {
if (!document.contains(row)) return;
video.srcObject = null;
previewDiv.textContent = "";
previewDiv.appendChild(createPlaceholder(onClickJoin));
});
// Track renegotiation: detect ended/mute and swap to placeholder
const track = stream.getVideoTracks()[0];
if (track !== undefined) {
const onTrackDead = (): void => {
if (!document.contains(row)) return;
video.srcObject = null;
previewDiv.textContent = "";
previewDiv.appendChild(createPlaceholder(onClickJoin));
};
track.addEventListener("ended", onTrackDead);
track.addEventListener("mute", onTrackDead);
// Store cleanup function
const state = previewTimers.get(row);
if (state !== undefined) {
state.trackCleanup = () => {
track.removeEventListener("ended", onTrackDead);
track.removeEventListener("mute", onTrackDead);
};
}
}
video.style.cursor = "pointer";
if (onClickWatch !== undefined) {
video.addEventListener("click", (e) => {
e.stopPropagation();
onClickWatch();
});
}
previewDiv.appendChild(video);
} else {
previewDiv.appendChild(createPlaceholder(onClickJoin));
}
// Screen reader announcement
const announcement = createElement("span", {
role: "status",
"aria-live": "polite",
class: "sr-only",
}, `Showing stream preview for ${username}`);
previewDiv.appendChild(announcement);
// Close when mouse leaves the preview div (but not if moving back to row)
previewDiv.addEventListener("mouseleave", () => {
if (row.matches(":hover")) return; // Moving back to row — keep open
hidePreview(row);
});
// Insert as sibling after the row (not inside it — row is display:flex)
row.after(previewDiv);
// Animate open — measure actual content height
requestAnimationFrame(() => {
if (!document.contains(previewDiv)) return;
previewDiv.style.height = `${previewDiv.scrollHeight}px`;
});
}
function createPlaceholder(onClickJoin?: () => void): HTMLElement {
const placeholder = createElement("div", {
class: "vu-preview-placeholder",
role: "button",
"aria-label": "Join channel to preview stream",
});
const icon = createIcon("monitor", 14);
icon.style.color = "var(--text-faint)";
placeholder.appendChild(icon);
const text = createElement("span", {}, "Join to preview");
placeholder.appendChild(text);
if (onClickJoin !== undefined) {
placeholder.addEventListener("click", (e) => {
e.stopPropagation();
onClickJoin();
});
}
return placeholder;
}
function hidePreview(row: HTMLElement): void {
const state = previewTimers.get(row);
if (state !== undefined) {
clearTimeout(state.debounce);
if (state.trackCleanup !== null) {
state.trackCleanup();
state.trackCleanup = null;
}
}
// Preview is a sibling after the row
const next = row.nextElementSibling;
const previewDiv = (next !== null && next.classList.contains("vu-preview")) ? next as HTMLElement : null;
if (previewDiv === null) {
previewTimers.delete(row);
return;
}
// Animate close
previewDiv.style.height = "0";
const animTimer = window.setTimeout(() => {
removePreviewDom(row);
previewTimers.delete(row);
}, 200);
if (state !== undefined) {
state.animation = animTimer;
}
}
/**
* Attach stream preview behavior to a voice-user-item row.
* Call once per row during render. Cleanup is automatic via the AbortSignal.
*/
export function attachStreamPreview(
row: HTMLElement,
userId: number,
username: string,
hasScreenshare: boolean,
hasCamera: boolean,
signal: AbortSignal,
onClickJoin?: () => void,
onClickWatch?: () => void,
): void {
const startPreview = (): void => {
clearPreviewState(row);
removePreviewDom(row);
const debounceTimer = window.setTimeout(() => {
showPreview(row, userId, username, hasScreenshare, hasCamera, onClickJoin, onClickWatch);
}, DEBOUNCE_MS);
previewTimers.set(row, {
debounce: debounceTimer,
animation: 0,
trackCleanup: null,
});
};
const stopPreview = (): void => {
hidePreview(row);
};
// Delayed stop — gives the user time to move mouse to the preview div
const stopPreviewDelayed = (): void => {
const state = previewTimers.get(row);
if (state !== undefined) {
clearTimeout(state.animation);
state.animation = window.setTimeout(() => {
// Check if mouse is now over the preview sibling
const preview = row.nextElementSibling;
if (preview !== null && preview.classList.contains("vu-preview") && preview.matches(":hover")) {
return; // Mouse moved to preview — keep it open
}
hidePreview(row);
}, 150);
} else {
hidePreview(row);
}
};
// Mouse handlers
row.addEventListener("mouseenter", startPreview, { signal });
row.addEventListener("mouseleave", stopPreviewDelayed, { signal });
// Keyboard accessibility: focus mirrors hover
row.addEventListener("focusin", startPreview, { signal });
row.addEventListener("focusout", stopPreview, { signal });
// Cleanup on abort (sidebar teardown)
signal.addEventListener("abort", () => {
clearPreviewState(row);
removePreviewDom(row);
});
}
/**
* Attach scroll listener to a voice-users-list container to collapse
* any open previews when the user scrolls. WebView2 doesn't always
* fire mouseleave on scroll.
*/
export function attachScrollCollapse(
container: HTMLElement,
signal: AbortSignal,
): void {
container.addEventListener("scroll", () => {
const openPreviews = container.querySelectorAll<HTMLElement>(".vu-preview");
for (const preview of openPreviews) {
// Preview is a sibling after the row — get the preceding voice-user-item
const row = preview.previousElementSibling;
if (row !== null && row.classList.contains("voice-user-item")) {
hidePreview(row as HTMLElement);
}
}
}, { signal, passive: true });
}
+1 -1
View File
@@ -46,6 +46,7 @@ import type { ChannelController } from "./main-page/ChannelController";
import { createUpdateNotifier } from "@components/UpdateNotifier";
import { createSidebarArea } from "./main-page/SidebarArea";
import { createChatArea } from "./main-page/ChatArea";
import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants";
const log = createLogger("main-page");
@@ -327,7 +328,6 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
setVoiceOnError((msg) => showToast(msg, "error"));
// Wire remote video callbacks to video grid
const SCREENSHARE_TILE_ID_OFFSET = 1_000_000;
setOnRemoteVideo((userId, stream, isScreenshare) => {
if (videoGrid === null) return;
const voice = voiceStore.getState();
@@ -5,6 +5,7 @@
import { voiceStore } from "@stores/voice.store";
import { getLocalCameraStream, getLocalScreenshareStream } from "@lib/livekitSession";
import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants";
import type { VideoGridComponent } from "@components/VideoGrid";
// ---------------------------------------------------------------------------
@@ -54,7 +55,6 @@ export function createVideoModeController(
let localTileAdded = false;
let localScreenshareTileAdded = false;
let focusedTileId: number | null = null;
const SCREENSHARE_TILE_ID_OFFSET = 1_000_000;
function showVideoGrid(): void {
if (videoMode) return;
+48
View File
@@ -164,6 +164,51 @@
flex-shrink: 0;
}
/* ── Inline stream preview (voice channel sidebar hover) ── */
.vu-preview {
margin: 2px 0;
height: 0;
overflow: hidden;
transition: height var(--transition-slow) ease;
}
.vu-preview video {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
border: 1px solid rgba(0, 200, 255, 0.15);
box-shadow: 0 0 4px rgba(0, 200, 255, 0.08);
display: block;
}
.vu-preview video.preview-camera { object-fit: cover; }
.vu-preview video.preview-screen { object-fit: cover; }
.vu-preview-placeholder {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
border: 1px solid rgba(0, 200, 255, 0.08);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--text-faint);
font-size: var(--font-size-xxs);
cursor: pointer;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Voice widget (above user bar, when connected) */
.voice-widget {
background: var(--bg-secondary); border-top: 1px solid var(--border);
@@ -1792,6 +1837,9 @@
/* Voice */
.compact-mode .voice-user-item { padding: 2px 8px; font-size: 12px; }
.compact-mode .voice-user-item .vu-avatar { width: 16px; height: 16px; font-size: 7px; }
.compact-mode .vu-preview { margin: 1px 0; }
.compact-mode .vu-preview video,
.compact-mode .vu-preview-placeholder { aspect-ratio: 16 / 9; }
/* User bar */
.compact-mode .user-bar { height: 42px; }
.compact-mode .user-bar .ub-avatar { width: 28px; height: 28px; font-size: 12px; }
@@ -1,4 +1,20 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// Mock livekitSession (required by streamPreview)
vi.mock("@lib/livekitSession", () => ({
setUserVolume: vi.fn(),
getUserVolume: vi.fn(() => 1),
getRemoteVideoStream: vi.fn(() => null),
}));
// Mock streamPreview to isolate sidebar tests from preview DOM logic
const mockAttachStreamPreview = vi.fn();
const mockAttachScrollCollapse = vi.fn();
vi.mock("@lib/streamPreview", () => ({
attachStreamPreview: (...args: unknown[]) => mockAttachStreamPreview(...args),
attachScrollCollapse: (...args: unknown[]) => mockAttachScrollCollapse(...args),
}));
import { createChannelSidebar } from "../../src/components/ChannelSidebar";
import {
channelsStore,
@@ -425,7 +441,8 @@ describe("ChannelSidebar", () => {
expect(voiceUserItem).not.toBeNull();
voiceUserItem.click();
expect(onWatchStream).toHaveBeenCalledWith(30);
// User has screenshare: true, so tileId = userId + SCREENSHARE_TILE_ID_OFFSET
expect(onWatchStream).toHaveBeenCalledWith(30 + 1_000_000);
});
// ── Empty state ──
@@ -1026,4 +1043,139 @@ describe("ChannelSidebar", () => {
const liveBadge = userRow!.querySelector(".vu-live-badge");
expect(liveBadge).not.toBeNull();
});
// T1: Screenshare click → offset tileId
it("passes screenshare tile offset when clicking screensharing user", () => {
const onWatchStream = vi.fn();
const sidebarWithWatch = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onWatchStream });
setChannels(testChannels);
voiceStore.setState(() => ({
currentChannelId: 3,
voiceUsers: new Map([
[3, new Map([[99, { userId: 99, username: "Streamer", speaking: false, muted: false, deafened: false, camera: false, screenshare: true }]])],
]),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
}));
sidebarWithWatch.mount(container);
const userRow = container.querySelector<HTMLElement>(".voice-user-item");
expect(userRow).not.toBeNull();
userRow!.click();
expect(onWatchStream).toHaveBeenCalledWith(99 + 1_000_000);
sidebarWithWatch.destroy?.();
});
// T2: Camera-only click → raw userId
it("passes raw userId when clicking camera-only user", () => {
const onWatchStream = vi.fn();
const sidebarWithWatch = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onWatchStream });
setChannels(testChannels);
voiceStore.setState(() => ({
currentChannelId: 3,
voiceUsers: new Map([
[3, new Map([[99, { userId: 99, username: "Cammer", speaking: false, muted: false, deafened: false, camera: true, screenshare: false }]])],
]),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
}));
sidebarWithWatch.mount(container);
const userRow = container.querySelector<HTMLElement>(".voice-user-item");
expect(userRow).not.toBeNull();
userRow!.click();
expect(onWatchStream).toHaveBeenCalledWith(99);
sidebarWithWatch.destroy?.();
});
// T12: Self-user → no preview attached
it("does not attach stream preview for self user", () => {
mockAttachStreamPreview.mockClear();
authStore.setState(() => ({
token: "tok",
user: { id: 42, username: "Me", avatar: null, role: "member" },
serverName: "Test Server",
motd: null,
isAuthenticated: true,
}));
setChannels(testChannels);
voiceStore.setState(() => ({
currentChannelId: 3,
voiceUsers: new Map([
[3, new Map([[42, { userId: 42, username: "Me", speaking: false, muted: false, deafened: false, camera: true, screenshare: false }]])],
]),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
}));
sidebar.mount(container);
// Should not have called attachStreamPreview for self
expect(mockAttachStreamPreview).not.toHaveBeenCalled();
});
// T20: Constant shared — sidebar uses SCREENSHARE_TILE_ID_OFFSET from constants
it("uses shared SCREENSHARE_TILE_ID_OFFSET constant", async () => {
// Verify the constant is imported and used by checking the offset value
const onWatchStream = vi.fn();
const sidebarWithWatch = createChannelSidebar({ onVoiceJoin, onVoiceLeave, onWatchStream });
setChannels(testChannels);
voiceStore.setState(() => ({
currentChannelId: 3,
voiceUsers: new Map([
[3, new Map([[1, { userId: 1, username: "User", speaking: false, muted: false, deafened: false, camera: false, screenshare: true }]])],
]),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
}));
sidebarWithWatch.mount(container);
container.querySelector<HTMLElement>(".voice-user-item")?.click();
// 1 + 1_000_000 = 1_000_001 — proves the shared constant is used
expect(onWatchStream).toHaveBeenCalledWith(1_000_001);
sidebarWithWatch.destroy?.();
});
// T14: attachScrollCollapse is called for voice users containers
it("attaches scroll collapse to voice-users-list containers", () => {
mockAttachScrollCollapse.mockClear();
setChannels(testChannels);
voiceStore.setState(() => ({
currentChannelId: 3,
voiceUsers: new Map([
[3, new Map([[99, { userId: 99, username: "User", speaking: false, muted: false, deafened: false, camera: true, screenshare: false }]])],
]),
voiceConfigs: new Map(),
localMuted: false,
localDeafened: false,
localCamera: false,
localScreenshare: false,
joinedAt: null,
listenOnly: false,
}));
sidebar.mount(container);
expect(mockAttachScrollCollapse).toHaveBeenCalled();
});
});
@@ -0,0 +1,336 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
// Mock livekitSession before importing streamPreview
const mockGetRemoteVideoStream = vi.fn<(uid: number, type: "camera" | "screenshare") => MediaStream | null>();
vi.mock("@lib/livekitSession", () => ({
getRemoteVideoStream: (uid: number, type: "camera" | "screenshare") => mockGetRemoteVideoStream(uid, type),
setUserVolume: vi.fn(),
getUserVolume: vi.fn(() => 1),
}));
vi.mock("@lib/icons", () => ({
createIcon: (name: string, size: number) => {
const el = document.createElement("span");
el.dataset.icon = name;
el.dataset.size = String(size);
return el;
},
}));
import { attachStreamPreview, attachScrollCollapse } from "../../src/lib/streamPreview";
// jsdom doesn't implement HTMLVideoElement.play() — provide a mock
beforeAll(() => {
HTMLVideoElement.prototype.play = vi.fn(() => Promise.resolve());
});
/** Get the preview sibling div after a row (preview is inserted as next sibling). */
function getPreview(row: HTMLElement): HTMLElement | null {
const next = row.nextElementSibling;
return (next !== null && next.classList.contains("vu-preview")) ? next as HTMLElement : null;
}
function createRow(userId: number): HTMLElement {
const row = document.createElement("div");
row.className = "voice-user-item";
row.dataset.voiceUid = String(userId);
document.body.appendChild(row);
return row;
}
function createMockMediaStream(trackState: "live" | "ended" = "live"): MediaStream {
const track = new EventTarget() as MediaStreamTrack;
Object.defineProperty(track, "readyState", { value: trackState });
Object.defineProperty(track, "kind", { value: "video" });
const stream = {
getVideoTracks: () => [track],
getTracks: () => [track],
} as unknown as MediaStream;
return stream;
}
describe("streamPreview", () => {
let ac: AbortController;
beforeEach(() => {
ac = new AbortController();
mockGetRemoteVideoStream.mockReset();
vi.useFakeTimers();
});
afterEach(() => {
ac.abort();
vi.useRealTimers();
document.body.innerHTML = "";
});
// T3: getRemoteVideoStream room null → null (via mock returning null)
it("shows placeholder when getRemoteVideoStream returns null", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const placeholder = getPreview(row)?.querySelector(".vu-preview-placeholder") ?? null;
expect(placeholder).not.toBeNull();
expect(placeholder?.textContent).toContain("Join to preview");
});
// T7: getRemoteVideoStream success → shows video
it("shows video when getRemoteVideoStream returns a stream", () => {
const stream = createMockMediaStream();
mockGetRemoteVideoStream.mockReturnValue(stream);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const video = getPreview(row)?.querySelector("video") ?? null;
expect(video).not.toBeNull();
expect(video?.srcObject).toBe(stream);
expect(video?.muted).toBe(true);
});
// T8: Hover creates preview video element
it("creates .vu-preview container on hover", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
expect(getPreview(row)).not.toBeNull();
});
// T9: Hover with no stream → shows placeholder
it("shows placeholder with icon and actionable text", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", true, false, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const placeholder = getPreview(row)?.querySelector(".vu-preview-placeholder") ?? null;
expect(placeholder).not.toBeNull();
expect(placeholder?.getAttribute("role")).toBe("button");
expect(placeholder?.getAttribute("aria-label")).toContain("Join channel to preview");
});
// T10: Mouseleave removes preview
it("removes preview on mouseleave after animation", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
expect(getPreview(row)).not.toBeNull();
row.dispatchEvent(new MouseEvent("mouseleave"));
vi.advanceTimersByTime(150 + 200); // 150ms delayed check + 200ms animation
expect(getPreview(row)).toBeNull();
});
// T11: Debounce: rapid hover/unhover → no preview
it("does not show preview if mouse leaves within debounce period", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(100); // Only 100ms, below 300ms debounce
row.dispatchEvent(new MouseEvent("mouseleave"));
vi.advanceTimersByTime(300);
expect(getPreview(row)).toBeNull();
});
// T13: Track ended → swaps to placeholder
it("swaps to placeholder when track ends", () => {
const stream = createMockMediaStream();
const track = stream.getVideoTracks()[0]!;
mockGetRemoteVideoStream.mockReturnValue(stream);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
expect(getPreview(row)?.querySelector("video") ?? null).not.toBeNull();
// Simulate track ending
track.dispatchEvent(new Event("ended"));
expect(getPreview(row)?.querySelector("video") ?? null).toBeNull();
expect(getPreview(row)?.querySelector(".vu-preview-placeholder") ?? null).not.toBeNull();
});
// T15: Focus/blur mirrors hover/leave
it("shows preview on focusin and hides on focusout", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new FocusEvent("focusin"));
vi.advanceTimersByTime(300);
expect(getPreview(row)).not.toBeNull();
row.dispatchEvent(new FocusEvent("focusout"));
vi.advanceTimersByTime(200);
expect(getPreview(row)).toBeNull();
});
// T16: ARIA labels present on video
it("sets aria-label on video element", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const video = getPreview(row)?.querySelector("video") ?? null;
expect(video?.getAttribute("aria-label")).toBe("Stream preview for Alice");
});
// T16b: Screen reader announcement
it("includes screen reader announcement", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const srAnnouncement = getPreview(row)?.querySelector(".sr-only") ?? null;
expect(srAnnouncement?.textContent).toContain("Showing stream preview for Alice");
});
// T17: Camera uses preview-camera class
it("uses preview-camera class for camera streams", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const video = getPreview(row)?.querySelector("video") ?? null;
expect(video?.className).toBe("preview-camera");
});
// T18: Screenshare uses preview-screen class
it("uses preview-screen class for screenshare streams", () => {
const stream = createMockMediaStream();
mockGetRemoteVideoStream.mockImplementation((uid, type) =>
type === "screenshare" ? stream : null,
);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", true, false, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const video = getPreview(row)?.querySelector("video") ?? null;
expect(video?.className).toBe("preview-screen");
});
// T4+T5+T6: getRemoteVideoStream various null returns → placeholder
it("tries screenshare first, falls back to camera", () => {
const cameraStream = createMockMediaStream();
mockGetRemoteVideoStream.mockImplementation((uid, type) =>
type === "camera" ? cameraStream : null,
);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", true, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
// Should have tried screenshare first, then camera
expect(mockGetRemoteVideoStream).toHaveBeenCalledWith(42, "screenshare");
expect(mockGetRemoteVideoStream).toHaveBeenCalledWith(42, "camera");
// Should show camera stream since screenshare returned null
const video = getPreview(row)?.querySelector("video") ?? null;
expect(video?.srcObject).toBe(cameraStream);
expect(video?.className).toBe("preview-camera");
});
// Abort signal cleanup
it("cleans up on abort signal", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
expect(getPreview(row)).not.toBeNull();
ac.abort();
expect(getPreview(row)).toBeNull();
});
// Track mute event → placeholder
it("swaps to placeholder on track mute event", () => {
const stream = createMockMediaStream();
const track = stream.getVideoTracks()[0]!;
mockGetRemoteVideoStream.mockReturnValue(stream);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
track.dispatchEvent(new Event("mute"));
expect(getPreview(row)?.querySelector("video") ?? null).toBeNull();
expect(getPreview(row)?.querySelector(".vu-preview-placeholder") ?? null).not.toBeNull();
});
});
describe("attachScrollCollapse", () => {
let ac: AbortController;
beforeEach(() => {
ac = new AbortController();
vi.useFakeTimers();
mockGetRemoteVideoStream.mockReset();
});
afterEach(() => {
ac.abort();
vi.useRealTimers();
document.body.innerHTML = "";
});
// T14: Scroll collapses open preview
it("collapses open previews on scroll", () => {
mockGetRemoteVideoStream.mockReturnValue(createMockMediaStream());
const container = document.createElement("div");
container.className = "voice-users-list";
document.body.appendChild(container);
const row = createRow(42);
container.appendChild(row);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
attachScrollCollapse(container, ac.signal);
// Show preview
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
expect(getPreview(row)).not.toBeNull();
// Scroll
container.dispatchEvent(new Event("scroll"));
vi.advanceTimersByTime(200);
expect(getPreview(row)).toBeNull();
});
});
+12
View File
@@ -18,6 +18,18 @@ Deferred work items from engineering reviews.
- ~~Adaptive Bitrate on Screenshare~~ -- `dynacast: !isSource` + `adaptiveStream: !isSource` in Room options (livekitSession.ts:187-188)
- ~~LiveKit Proxy Port Exhaustion~~ -- already handles reuse (same host) + cleanup via shutdown channel (different host) in livekit_proxy.rs:196-208
## Deferred (from 2026-03-30 eng review)
### Remote Video Stream Reuse (getRemoteVideoStream)
**What:** The `getRemoteVideoStream(userId, type)` accessor added for sidebar preview can be reused for PiP, mini-player, or notification previews.
**Why:** Currently only used by stream preview hover. Future features (PiP mode, floating mini-player, notification thumbnails) would benefit from the same API.
**Pros:** Zero additional work — the export already exists in livekitSession.ts. This TODO just tracks the reuse opportunity.
**Cons:** None — purely informational. No code change needed.
**Context:** Added during sidebar stream preview eng review (2026-03-30). The method is exported as a bound module-level function, consistent with getLocalCameraStream/getLocalScreenshareStream. Any future consumer can import it directly.
**Depends on:** Sidebar stream preview feature (this PR).
**Added:** 2026-03-30 (eng review of sidebar stream preview)
## Deferred (from 2026-03-29 CEO review)
### Voice E2E CI Integration (narrowed scope)