From 5d0af7c03e42a12a455071cecbd34d32757d4bb0 Mon Sep 17 00:00:00 2001 From: jevb Date: Mon, 30 Mar 2026 19:12:36 +0200 Subject: [PATCH] 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). --- .../src/components/ChannelSidebar.ts | 30 +- Client/tauri-client/src/lib/constants.ts | 2 + Client/tauri-client/src/lib/livekitSession.ts | 14 + Client/tauri-client/src/lib/streamPreview.ts | 283 +++++++++++++++ Client/tauri-client/src/pages/MainPage.ts | 2 +- .../pages/main-page/VideoModeController.ts | 2 +- Client/tauri-client/src/styles/app.css | 48 +++ .../tests/unit/channel-sidebar.test.ts | 154 +++++++- .../tests/unit/stream-preview.test.ts | 336 ++++++++++++++++++ TODOS.md | 12 + 10 files changed, 879 insertions(+), 4 deletions(-) create mode 100644 Client/tauri-client/src/lib/constants.ts create mode 100644 Client/tauri-client/src/lib/streamPreview.ts create mode 100644 Client/tauri-client/tests/unit/stream-preview.test.ts diff --git a/Client/tauri-client/src/components/ChannelSidebar.ts b/Client/tauri-client/src/components/ChannelSidebar.ts index 690c4cea..c83457ea 100644 --- a/Client/tauri-client/src/components/ChannelSidebar.ts +++ b/Client/tauri-client/src/components/ChannelSidebar.ts @@ -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); } diff --git a/Client/tauri-client/src/lib/constants.ts b/Client/tauri-client/src/lib/constants.ts new file mode 100644 index 00000000..20c10dc8 --- /dev/null +++ b/Client/tauri-client/src/lib/constants.ts @@ -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; diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 472d6113..34dede22 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -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); diff --git a/Client/tauri-client/src/lib/streamPreview.ts b/Client/tauri-client/src/lib/streamPreview.ts new file mode 100644 index 00000000..1e04e26a --- /dev/null +++ b/Client/tauri-client/src/lib/streamPreview.ts @@ -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