Files
OwnCord/Client/tauri-client/src/components/VideoGrid.ts
T
jevb 39658e919b refactor: extensibility overhaul — handler registry, permission checker, sidebar decomposition, DX improvements
Server:
- Unified permission checker (permissions/checker.go) replaces 3 duplicated implementations
- WS handler registry pattern (ws/registry.go) replaces monolithic switch (747→184 lines)
- Split handlers into domain files: handlers_chat.go, handlers_presence.go, handlers_reaction.go
- Shared message type constants (ws/message_types.go) — no more string literals
- Admin API split into helpers.go, types.go, middleware.go (api.go now 61 lines)
- Dev seed script (scripts/seed.go) with -confirm-dev safety flag
- Air hot reload config (.air.toml)
- Fix: DM attachment permission now uses participant check, not role check
- Fix: Typing broadcast now checks ReadMessages permission for non-DM channels

Client:
- Extract preferences to @lib/preferences.ts (fixes lib→component dependency)
- Extract roles to dedicated roles.store.ts (was mixed into channels store)
- Decompose SidebarArea (921→598 lines) into 4 sub-components
- Shared modal factory (lib/modalFactory.ts) with tests
- Global showToast() helper (lib/toast.ts) — 18 call sites migrated
- Protocol type constants (lib/protocolTypes.ts) synced with server
- Remove 38 unnecessary type casts across 17 files
- Component test harness (tests/helpers/test-harness.ts) with 8 tests
- Fix: DM section "View All" respects collapsed state
- Fix: Modal onClose fires on external signal abort
- Fix: savePref wrapped in try/catch for quota exceeded
- Fix: loadPref null guard added

Triple-reviewed: Claude code-review agent + OpenAI Codex CLI + GitHub Copilot
2026-03-29 12:19:08 +02:00

293 lines
9.0 KiB
TypeScript

/**
* VideoGrid component — renders remote video streams in a responsive CSS grid.
* Replaces the chat area when cameras are active.
*/
import { createElement, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { muteScreenshareAudio, setUserVolume } from "@lib/livekitSession";
import type { MountableComponent } from "@lib/safe-render";
export interface TileConfig {
/** True if this is the local user's own tile (no audio controls) */
readonly isSelf: boolean;
/** The real userId for audio control (differs from tile ID for screenshare tiles) */
readonly audioUserId: number;
/** True if this tile represents a screenshare (vs camera) */
readonly isScreenshare: boolean;
}
export interface VideoGridComponent extends MountableComponent {
addStream(userId: number, username: string, stream: MediaStream, config?: TileConfig): void;
removeStream(userId: number): void;
hasStreams(): boolean;
setFocusedTile(tileId: number): void;
getFocusedTileId(): number | null;
}
/** Create a fresh volume icon element. */
function volumeIcon(): SVGSVGElement { return createIcon("volume-2", 16); }
/** Create a fresh volume-x (muted) icon element. */
function volumeXIcon(): SVGSVGElement { return createIcon("volume-x", 16); }
/** Replace a button's icon child with a new one. */
function setButtonIcon(btn: HTMLButtonElement, icon: SVGSVGElement): void {
while (btn.firstChild) btn.removeChild(btn.firstChild);
btn.appendChild(icon);
}
function computeGridColumns(count: number): string {
if (count <= 1) return "1fr";
if (count <= 4) return "1fr 1fr";
if (count <= 9) return "1fr 1fr 1fr";
return "1fr 1fr 1fr 1fr";
}
export function createVideoGrid(): VideoGridComponent {
let root: HTMLDivElement | null = null;
const cells = new Map<number, { el: HTMLDivElement; config?: TileConfig }>();
let focusedTileId: number | null = null;
function rebuildFocusLayout(): void {
if (root === null) return;
// Clear root children (we'll re-append in focus layout order)
while (root.firstChild) root.removeChild(root.firstChild);
if (focusedTileId === null || cells.size === 0) {
// No focus — use regular grid layout
root.classList.remove("focus-mode");
root.style.gridTemplateColumns = computeGridColumns(cells.size);
for (const entry of cells.values()) {
entry.el.classList.remove("focused", "thumb");
root.appendChild(entry.el);
}
return;
}
root.classList.add("focus-mode");
root.style.gridTemplateColumns = ""; // Clear grid columns, focus uses flex
// Main area
const mainArea = createElement("div", { class: "video-focus-main" });
// Strip area
const stripArea = createElement("div", { class: "video-focus-strip" });
const focusedEntry = cells.get(focusedTileId);
if (focusedEntry !== undefined) {
focusedEntry.el.classList.add("focused");
focusedEntry.el.classList.remove("thumb");
mainArea.appendChild(focusedEntry.el);
}
for (const [id, entry] of cells) {
if (id === focusedTileId) continue;
entry.el.classList.remove("focused");
entry.el.classList.add("thumb");
stripArea.appendChild(entry.el);
}
root.appendChild(mainArea);
// Only show strip if there are thumbnails
if (stripArea.childElementCount > 0) {
root.appendChild(stripArea);
}
}
function setFocusedTile(tileId: number): void {
focusedTileId = tileId;
rebuildFocusLayout();
}
function getFocusedTileIdFn(): number | null {
return focusedTileId;
}
function updateLayout(): void {
if (root === null) return;
if (focusedTileId !== null) {
rebuildFocusLayout();
return;
}
root.style.gridTemplateColumns = computeGridColumns(cells.size);
}
function addStream(userId: number, username: string, stream: MediaStream, config?: TileConfig): void {
if (root === null) return;
// If a cell already exists for this user, update it in place
const existing = cells.get(userId);
if (existing !== undefined) {
const video = existing.el.querySelector("video");
if (video !== null) {
// Only replace srcObject if the underlying tracks changed
const oldTracks = (video.srcObject as MediaStream | null)?.getTracks() ?? [];
const newTracks = stream.getTracks();
const tracksMatch =
oldTracks.length === newTracks.length &&
oldTracks.every((t, i) => t.id === newTracks[i]?.id);
if (!tracksMatch) {
video.srcObject = stream;
}
}
// Update username label in case it changed
const label = existing.el.querySelector(".video-username");
if (label !== null) {
label.textContent = username;
}
return;
}
const video = createElement("video", {
autoplay: "",
playsinline: "",
});
video.muted = true;
video.srcObject = stream;
const label = createElement("div", { class: "video-username" }, username);
const cell = createElement("div", {
class: "video-cell",
"data-user-id": String(userId),
});
appendChildren(cell, video, label);
cell.addEventListener("click", (e) => {
// Don't switch focus if clicking the mute button
if ((e.target as Element).closest(".tile-mute-btn")) return;
if (focusedTileId !== null && focusedTileId !== userId) {
focusedTileId = userId;
rebuildFocusLayout();
}
});
// Add audio control overlay for remote tiles
if (config !== undefined && !config.isSelf) {
let muted = false;
let currentVolume = 100;
const overlay = createElement("div", { class: "video-tile-overlay" });
// Volume slider
const volumeSlider = createElement("input", {
type: "range",
min: "0",
max: "200",
value: "100",
class: "tile-volume-slider",
"aria-label": "Volume",
});
volumeSlider.addEventListener("input", () => {
currentVolume = Number(volumeSlider.value);
const wasMuted = muted;
muted = currentVolume === 0;
if (config.isScreenshare) {
muteScreenshareAudio(config.audioUserId, muted);
} else {
setUserVolume(config.audioUserId, currentVolume);
}
setButtonIcon(muteBtn, muted ? volumeXIcon() : volumeIcon());
muteBtn.setAttribute("aria-label", muted ? "Unmute" : "Mute");
if (muted !== wasMuted) {
overlay.classList.toggle("muted", muted);
}
});
// Mute button
const muteBtn = createElement("button", {
class: "tile-mute-btn",
"aria-label": "Mute",
});
muteBtn.appendChild(volumeIcon());
muteBtn.addEventListener("click", () => {
muted = !muted;
if (muted) {
if (config.isScreenshare) {
muteScreenshareAudio(config.audioUserId, true);
} else {
setUserVolume(config.audioUserId, 0);
}
volumeSlider.value = "0";
} else {
if (currentVolume === 0) currentVolume = 100;
if (config.isScreenshare) {
muteScreenshareAudio(config.audioUserId, false);
} else {
setUserVolume(config.audioUserId, currentVolume);
}
volumeSlider.value = String(currentVolume);
}
setButtonIcon(muteBtn, muted ? volumeXIcon() : volumeIcon());
muteBtn.setAttribute("aria-label", muted ? "Unmute" : "Mute");
overlay.classList.toggle("muted", muted);
});
overlay.appendChild(volumeSlider);
overlay.appendChild(muteBtn);
cell.appendChild(overlay);
}
cells.set(userId, { el: cell, config });
root.appendChild(cell);
if (focusedTileId !== null) {
rebuildFocusLayout();
} else {
updateLayout();
}
}
function removeStream(userId: number): void {
const entry = cells.get(userId);
if (entry === undefined) return;
const video = entry.el.querySelector("video");
if (video !== null) video.srcObject = null;
entry.el.remove();
cells.delete(userId);
// If focused tile was removed, focus the first remaining tile or clear
const wasFocusMode = focusedTileId !== null;
if (focusedTileId === userId) {
const firstKey = cells.keys().next().value;
focusedTileId = firstKey ?? null;
}
if (focusedTileId !== null || wasFocusMode) {
rebuildFocusLayout();
} else {
updateLayout();
}
}
function hasStreams(): boolean {
return cells.size > 0;
}
function mount(container: Element): void {
root = createElement("div", {
class: "video-grid",
"data-testid": "video-grid",
});
container.appendChild(root);
}
function destroy(): void {
for (const [, entry] of cells) {
const video = entry.el.querySelector("video");
if (video !== null) video.srcObject = null;
}
cells.clear();
focusedTileId = null;
if (root !== null) {
root.remove();
root = null;
}
}
return { mount, destroy, addStream, removeStream, hasStreams, setFocusedTile, getFocusedTileId: getFocusedTileIdFn };
}