diff --git a/apps/desktop/src/components/InCallPanel.tsx b/apps/desktop/src/components/InCallPanel.tsx index b6d2607..94942a9 100644 --- a/apps/desktop/src/components/InCallPanel.tsx +++ b/apps/desktop/src/components/InCallPanel.tsx @@ -1,7 +1,7 @@ import type { ConversationSummary } from '@chat-app/shared/chat'; import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client'; import { RoomEvent, Track } from 'livekit-client'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../context/AuthContext'; @@ -195,22 +195,6 @@ export function InCallPanel({ conversation }: Props) { if (soundboardCount === 0) setSoundboardOpen(false); }, [soundboardCount]); - // Active-speaker auto-focus uses "who most recently started speaking" - // rather than "exactly one speaker" — matches Discord more closely and - // handles the case where two people talk briefly without the focus - // collapsing to nobody. - const [lastStartedSpeakerId, setLastStartedSpeakerId] = useState(null); - const prevActiveSpeakersRef = useRef>(new Set()); - useEffect(() => { - for (const id of activeSpeakers) { - if (!prevActiveSpeakersRef.current.has(id)) { - setLastStartedSpeakerId(id); - break; - } - } - prevActiveSpeakersRef.current = new Set(activeSpeakers); - }, [activeSpeakers]); - // Single right-click dispatcher for all tiles. User-tiles open the volume // menu; screen-tiles open the share-specific menu (volume + mute + stop // watching). Self-tiles get no menu — no volume to control, and you can @@ -385,24 +369,13 @@ export function InCallPanel({ conversation }: Props) { })); if (callMode === 'fullscreen') { - // In fullscreen, a "manual focus" = user explicitly picked someone OR - // the person who most recently started speaking (tracked in - // lastStartedSpeakerId). Screen shares are no longer an auto-focus - // trigger; they stay as equal-size grid tiles until the user clicks - // one. "Most recent speaker" beats "exactly one currently speaking" - // because two people briefly overlapping shouldn't kick us out of - // auto-focus. - const autoSpeaker = - focusedId === null && lastStartedSpeakerId !== null - ? tiles.find( - (t) => - t.kind === 'user' && - !t.self && - t.userId === lastStartedSpeakerId, - ) - : undefined; - const hasFocus = focusedId !== null || autoSpeaker !== undefined; - const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined; + // Discord-style: without an explicit pin, fullscreen stays as a stable + // equal-size grid. Focus only kicks in when the user pins a tile + // (focusedId !== null). Auto-promoting the latest speaker caused the + // whole layout to flip on every utterance, which is not what users + // expect from a "cinema" view. + const hasFocus = focusedId !== null; + const effectiveSpeaker = hasFocus ? speaker : undefined; return ( <> {micError && ( @@ -1032,7 +1005,7 @@ function CallStage({ {others.map((p) => (
-
+
{tiles.map((p) => ( -
+
p.id !== speaker!.id) : []; // Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles - // exist, slice them into pages. Reset to page 0 if the page count drops - // below the current page (someone left). + // exist, slice them into pages and prioritize active speakers onto page 1. + // Otherwise keep a stable order (Discord-style) so tiles don't shuffle + // whenever someone speaks. + const needsPagination = tiles.length > GRID_PAGE_SIZE; const sortedGridTiles = useMemo( - () => prioritizeTiles(tiles, activeSpeakers), - [tiles, activeSpeakers], + () => (needsPagination ? prioritizeTiles(tiles, activeSpeakers) : tiles), + [tiles, activeSpeakers, needsPagination], ); const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE)); useEffect(() => {