mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -23,6 +23,7 @@ skills/
|
||||
Server/chatserver.exe
|
||||
Server/chatserver.exe~
|
||||
Server/owncord-server.exe
|
||||
Server/server.exe
|
||||
Server/config.yaml
|
||||
Server/data/
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user