feat: add stream preview, video grid track lifecycle, and build cleanup

- Integrate stream preview into VoiceChannel sidebar for remote users
  with active camera/screenshare
- Add track lifecycle listeners (ended/mute) to VideoGrid to auto-remove
  stale black tiles
- Call video.play() explicitly for WebView2 autoplay compatibility
- Prevent redundant voice join when already in channel (ChannelSidebar)
- Add attachScrollCollapse for preview cleanup on scroll
- Remove tauri_typegen from build.rs
- Add Server/server.exe to gitignore
- Add stream-preview and video-mode-controller test coverage
This commit is contained in:
jevb
2026-04-01 17:07:02 +02:00
parent b1d633029c
commit 8f45307ec5
10 changed files with 240 additions and 20 deletions
+1
View File
@@ -23,6 +23,7 @@ skills/
Server/chatserver.exe
Server/chatserver.exe~
Server/owncord-server.exe
Server/server.exe
Server/config.yaml
Server/data/
-4
View File
@@ -1,7 +1,3 @@
fn main() {
// Generate TypeScript bindings from #[tauri::command] functions
tauri_typegen::BuildSystem::generate_at_build_time()
.expect("Failed to generate TypeScript bindings");
tauri_build::build()
}
@@ -230,7 +230,10 @@ function renderVoiceChannelItem(
signal,
() => {
// Placeholder click: join voice channel and watch stream
onVoiceJoin(channel.id);
// Only join if not already in this channel
if (voiceStore.getState().currentChannelId !== channel.id) {
onVoiceJoin(channel.id);
}
if (onWatchStream !== undefined) onWatchStream(tileId);
},
onWatchStream !== undefined ? () => onWatchStream(tileId) : undefined,
@@ -97,7 +97,10 @@ export function computeGridLayout(
export function createVideoGrid(): VideoGridComponent {
let root: HTMLDivElement | null = null;
const cells = new Map<number, { el: HTMLDivElement; config?: TileConfig }>();
const cells = new Map<
number,
{ el: HTMLDivElement; config?: TileConfig; trackCleanup?: () => void }
>();
let focusedTileId: number | null = null;
let resizeObserver: ResizeObserver | null = null;
let resizeRafId = 0;
@@ -117,6 +120,33 @@ export function createVideoGrid(): VideoGridComponent {
}
}
/** Attach ended/mute listeners on the first video track to auto-remove stale tiles. */
function attachTrackLifecycle(userId: number, stream: MediaStream): void {
// Clean up previous listeners for this tile
const prev = cells.get(userId);
if (prev?.trackCleanup) {
prev.trackCleanup();
prev.trackCleanup = undefined;
}
const track = stream.getVideoTracks()[0];
if (track === undefined) return;
const onTrackDead = (): void => {
removeStream(userId);
};
track.addEventListener("ended", onTrackDead);
track.addEventListener("mute", onTrackDead);
const entry = cells.get(userId);
if (entry !== undefined) {
entry.trackCleanup = () => {
track.removeEventListener("ended", onTrackDead);
track.removeEventListener("mute", onTrackDead);
};
}
}
/** Schedule a layout recalculation on the next animation frame. */
function scheduleResize(): void {
if (resizeRafId !== 0) cancelAnimationFrame(resizeRafId);
@@ -216,6 +246,8 @@ export function createVideoGrid(): VideoGridComponent {
oldTracks.every((t, i) => t.id === newTracks[i]?.id);
if (!tracksMatch) {
video.srcObject = stream;
video.play()?.catch(() => {});
attachTrackLifecycle(userId, stream);
}
}
// Update username label in case it changed
@@ -234,6 +266,7 @@ export function createVideoGrid(): VideoGridComponent {
});
video.muted = true;
video.srcObject = stream;
video.play()?.catch(() => {});
const label = createElement("div", { class: "video-username" }, username);
@@ -323,6 +356,7 @@ export function createVideoGrid(): VideoGridComponent {
}
cells.set(userId, { el: cell, config });
attachTrackLifecycle(userId, stream);
root.appendChild(cell);
if (focusedTileId !== null) {
rebuildFocusLayout();
@@ -335,6 +369,11 @@ export function createVideoGrid(): VideoGridComponent {
const entry = cells.get(userId);
if (entry === undefined) return;
if (entry.trackCleanup) {
entry.trackCleanup();
entry.trackCleanup = undefined;
}
const video = entry.el.querySelector("video");
if (video !== null) video.srcObject = null;
@@ -383,6 +422,10 @@ export function createVideoGrid(): VideoGridComponent {
}
for (const [, entry] of cells) {
if (entry.trackCleanup) {
entry.trackCleanup();
entry.trackCleanup = undefined;
}
const video = entry.el.querySelector("video");
if (video !== null) video.srcObject = null;
}
@@ -11,11 +11,14 @@ import type { VoiceUser } from "@stores/voice.store";
import { membersStore } from "@stores/members.store";
import { setUserVolume, getUserVolume } from "@lib/livekitSession";
import { authStore } from "@stores/auth.store";
import { attachStreamPreview, attachScrollCollapse } from "@lib/streamPreview";
import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants";
export interface VoiceChannelOptions {
channelId: number;
channelName: string;
onJoin(): void;
onClickWatch?(tileId: number): void;
}
export interface VoiceChannelResult {
@@ -233,8 +236,35 @@ export function createVoiceChannel(options: VoiceChannelOptions): VoiceChannelRe
const username = (member as { username?: string } | undefined)?.username ?? "Unknown";
const row = createUserRow(user, username);
usersContainer.appendChild(row);
// Attach stream preview for remote users with active video
const currentUser = authStore.getState().user;
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,
username,
user.screenshare,
user.camera,
ac.signal,
() => {
// Only join if not already in this channel
if (voiceStore.getState().currentChannelId !== options.channelId) {
options.onJoin();
}
if (options.onClickWatch !== undefined) options.onClickWatch(tileId);
},
options.onClickWatch !== undefined ? () => options.onClickWatch!(tileId) : undefined,
);
}
}
attachScrollCollapse(usersContainer, ac.signal);
// Mark channel-item active if there are users
if (channelUsers.size > 0) {
channelItem.classList.add("active");
+27 -1
View File
@@ -12,6 +12,7 @@
import { createElement } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { getRemoteVideoStream } from "@lib/livekitSession";
import { voiceStore } from "@stores/voice.store";
/** Internal state tracked per voice-user-item row for cleanup. */
interface PreviewState {
@@ -118,7 +119,12 @@ function showPreview(
}
previewDiv.appendChild(video);
} else {
previewDiv.appendChild(createPlaceholder(onClickJoin));
const isInChannel = voiceStore.getState().currentChannelId !== null;
if (isInChannel) {
previewDiv.appendChild(createUnavailablePlaceholder(onClickWatch));
} else {
previewDiv.appendChild(createPlaceholder(onClickJoin));
}
}
// Screen reader announcement
@@ -169,6 +175,26 @@ function createPlaceholder(onClickJoin?: () => void): HTMLElement {
return placeholder;
}
function createUnavailablePlaceholder(onClickWatch?: () => void): HTMLElement {
const placeholder = createElement("div", {
class: "vu-preview-placeholder",
role: "button",
"aria-label": "Stream unavailable",
});
const icon = createIcon("monitor-off", 14);
icon.style.color = "var(--text-faint)";
placeholder.appendChild(icon);
const text = createElement("span", {}, "Stream unavailable");
placeholder.appendChild(text);
if (onClickWatch !== undefined) {
placeholder.addEventListener("click", (e) => {
e.stopPropagation();
onClickWatch();
});
}
return placeholder;
}
function hidePreview(row: HTMLElement): void {
const state = previewTimers.get(row);
if (state !== undefined) {
@@ -88,7 +88,9 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid
return;
}
// Check if any camera or screenshare is active
// Check if any camera or screenshare is active.
// Check both voice store state AND whether the grid has tiles, because
// LiveKit track delivery can race ahead of the WS voice_state update.
let anyVideoOn = voice.localCamera || voice.localScreenshare;
if (!anyVideoOn) {
for (const user of channelUsers.values()) {
@@ -98,6 +100,9 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid
}
}
}
if (!anyVideoOn) {
anyVideoOn = videoGrid.hasStreams();
}
// Auto-close video grid when no streams remain
if (!anyVideoOn && videoMode) {
showChat();
@@ -145,14 +150,11 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid
localScreenshareTileAdded = false;
}
// Remove remote video tiles for users who turned off their camera or screenshare
if (channelUsers) {
for (const user of channelUsers.values()) {
if (!user.camera && !user.screenshare && user.userId !== currentUserId) {
videoGrid.removeStream(user.userId);
}
}
}
// Remote video tiles are managed exclusively by the onRemoteVideo /
// onRemoteVideoRemoved callbacks (driven by LiveKit TrackSubscribed /
// TrackUnsubscribed). Do NOT remove remote tiles here based on voice
// store state — the WS voice_state update can lag behind LiveKit track
// delivery, causing tiles to be removed immediately after being added.
}
function isVideoModeActive(): boolean {
@@ -10,6 +10,16 @@ vi.mock("@lib/livekitSession", () => ({
getUserVolume: vi.fn(() => 1),
}));
const mockVoiceStoreState = {
currentChannelId: null as number | null,
localDeafened: false,
};
vi.mock("@stores/voice.store", () => ({
voiceStore: {
getState: () => mockVoiceStoreState,
},
}));
vi.mock("@lib/icons", () => ({
createIcon: (name: string, size: number) => {
const el = document.createElement("span");
@@ -57,6 +67,8 @@ describe("streamPreview", () => {
beforeEach(() => {
ac = new AbortController();
mockGetRemoteVideoStream.mockReset();
mockVoiceStoreState.currentChannelId = null;
mockVoiceStoreState.localDeafened = false;
vi.useFakeTimers();
});
@@ -295,6 +307,110 @@ describe("streamPreview", () => {
});
});
describe("streamPreview — channel-aware placeholder", () => {
let ac: AbortController;
beforeEach(() => {
ac = new AbortController();
mockGetRemoteVideoStream.mockReset();
mockVoiceStoreState.currentChannelId = null;
mockVoiceStoreState.localDeafened = false;
vi.useFakeTimers();
});
afterEach(() => {
ac.abort();
vi.useRealTimers();
document.body.innerHTML = "";
});
it("shows 'Join to preview' when NOT in a voice channel and stream is null", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
mockVoiceStoreState.currentChannelId = null;
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const placeholder = row.nextElementSibling?.querySelector(".vu-preview-placeholder");
expect(placeholder?.textContent).toContain("Join to preview");
});
it("does NOT show 'Join to preview' when already in a voice channel and stream is null", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
mockVoiceStoreState.currentChannelId = 1;
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const placeholder = row.nextElementSibling?.querySelector(".vu-preview-placeholder");
expect(placeholder).not.toBeNull();
// Should NOT say "Join to preview" when already in channel
expect(placeholder?.textContent).not.toContain("Join to preview");
});
it("shows 'Stream unavailable' when in channel but no stream available", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
mockVoiceStoreState.currentChannelId = 1;
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const placeholder = row.nextElementSibling?.querySelector(".vu-preview-placeholder");
expect(placeholder?.textContent).toContain("Stream unavailable");
});
it("uses onClickWatch when in channel and stream is null but onClickWatch provided", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
mockVoiceStoreState.currentChannelId = 1;
const onClickJoin = vi.fn();
const onClickWatch = vi.fn();
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal, onClickJoin, onClickWatch);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const placeholder = row.nextElementSibling?.querySelector(
".vu-preview-placeholder",
) as HTMLElement;
placeholder?.click();
expect(onClickWatch).toHaveBeenCalledOnce();
expect(onClickJoin).not.toHaveBeenCalled();
});
it("uses onClickJoin when NOT in channel and stream is null", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
mockVoiceStoreState.currentChannelId = null;
const onClickJoin = vi.fn();
const onClickWatch = vi.fn();
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal, onClickJoin, onClickWatch);
row.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(300);
const placeholder = row.nextElementSibling?.querySelector(
".vu-preview-placeholder",
) as HTMLElement;
placeholder?.click();
expect(onClickJoin).toHaveBeenCalledOnce();
expect(onClickWatch).not.toHaveBeenCalled();
});
});
describe("attachScrollCollapse", () => {
let ac: AbortController;
@@ -46,6 +46,7 @@ function makeVideoGrid(): VideoModeControllerOptions["videoGrid"] {
destroy: vi.fn(),
addStream: vi.fn(),
removeStream: vi.fn(),
hasStreams: vi.fn(() => false),
setFocusedTile: vi.fn(),
getFocusedTileId: vi.fn(() => null),
} as unknown as VideoModeControllerOptions["videoGrid"];
@@ -213,7 +214,7 @@ describe("createVideoModeController", () => {
expect(vg.removeStream).toHaveBeenCalledWith(1);
});
it("removes remote tile when remote user turns off camera", () => {
it("does NOT remove remote tiles in checkVideoMode (delegated to onRemoteVideoRemoved)", () => {
const users = new Map([
[1, { userId: 1, camera: false, screenshare: false, username: "me" }],
[2, { userId: 2, camera: false, screenshare: false, username: "bob" }],
@@ -233,7 +234,9 @@ describe("createVideoModeController", () => {
});
ctrl.checkVideoMode();
expect(vg.removeStream).toHaveBeenCalledWith(2);
// Remote tile removal is handled by onRemoteVideoRemoved (LiveKit TrackUnsubscribed),
// not by checkVideoMode, to avoid race conditions with voice store updates.
expect(vg.removeStream).not.toHaveBeenCalledWith(2);
});
it("showChat switches back to chat mode", () => {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES2021",
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
@@ -12,7 +12,7 @@
"isolatedModules": true,
"noEmit": true,
"lib": [
"ES2021",
"ES2023",
"DOM",
"DOM.Iterable"
],