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