diff --git a/.gitignore b/.gitignore index 11b93c9a..f68c7211 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ skills/ Server/chatserver.exe Server/chatserver.exe~ Server/owncord-server.exe +Server/server.exe Server/config.yaml Server/data/ diff --git a/Client/tauri-client/src-tauri/build.rs b/Client/tauri-client/src-tauri/build.rs index 4b94c115..d860e1e6 100644 --- a/Client/tauri-client/src-tauri/build.rs +++ b/Client/tauri-client/src-tauri/build.rs @@ -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() } diff --git a/Client/tauri-client/src/components/ChannelSidebar.ts b/Client/tauri-client/src/components/ChannelSidebar.ts index f7c8a5cc..b4ad9b62 100644 --- a/Client/tauri-client/src/components/ChannelSidebar.ts +++ b/Client/tauri-client/src/components/ChannelSidebar.ts @@ -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, diff --git a/Client/tauri-client/src/components/VideoGrid.ts b/Client/tauri-client/src/components/VideoGrid.ts index b6545e19..1831d856 100644 --- a/Client/tauri-client/src/components/VideoGrid.ts +++ b/Client/tauri-client/src/components/VideoGrid.ts @@ -97,7 +97,10 @@ export function computeGridLayout( export function createVideoGrid(): VideoGridComponent { let root: HTMLDivElement | null = null; - const cells = new Map(); + 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; } diff --git a/Client/tauri-client/src/components/VoiceChannel.ts b/Client/tauri-client/src/components/VoiceChannel.ts index a7870b41..6d0dedcb 100644 --- a/Client/tauri-client/src/components/VoiceChannel.ts +++ b/Client/tauri-client/src/components/VoiceChannel.ts @@ -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"); diff --git a/Client/tauri-client/src/lib/streamPreview.ts b/Client/tauri-client/src/lib/streamPreview.ts index 483dd7b6..561e4281 100644 --- a/Client/tauri-client/src/lib/streamPreview.ts +++ b/Client/tauri-client/src/lib/streamPreview.ts @@ -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) { diff --git a/Client/tauri-client/src/pages/main-page/VideoModeController.ts b/Client/tauri-client/src/pages/main-page/VideoModeController.ts index 7136347f..bf9206e2 100644 --- a/Client/tauri-client/src/pages/main-page/VideoModeController.ts +++ b/Client/tauri-client/src/pages/main-page/VideoModeController.ts @@ -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 { diff --git a/Client/tauri-client/tests/unit/stream-preview.test.ts b/Client/tauri-client/tests/unit/stream-preview.test.ts index 78382bdb..a16d225f 100644 --- a/Client/tauri-client/tests/unit/stream-preview.test.ts +++ b/Client/tauri-client/tests/unit/stream-preview.test.ts @@ -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; diff --git a/Client/tauri-client/tests/unit/video-mode-controller.test.ts b/Client/tauri-client/tests/unit/video-mode-controller.test.ts index f7accb6e..9e21efb7 100644 --- a/Client/tauri-client/tests/unit/video-mode-controller.test.ts +++ b/Client/tauri-client/tests/unit/video-mode-controller.test.ts @@ -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", () => { diff --git a/Client/tauri-client/tsconfig.json b/Client/tauri-client/tsconfig.json index b7e3492c..784de97f 100644 --- a/Client/tauri-client/tsconfig.json +++ b/Client/tauri-client/tsconfig.json @@ -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" ],