From 63a7de402be1d9d03b1d2146c3558a911f0efa72 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 13 Jul 2026 18:50:37 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20extract=20pinned?= =?UTF-8?q?=20track=20a11y=20announcement=20into=20a=20leaf=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the code responsible for announcing pinned track changes (for accessibility) into a well-scoped leaf component. Previously, calling the useScreenReaderAnnounce hook inside StageLayout triggered a re-render of the whole layout subtree. Encapsulating the announcement logic in a narrow component keeps those re-renders local to that leaf. --- .../layout/components/PinAnnouncer.tsx | 6 ++ .../layout/components/StageLayout.tsx | 83 +------------------ .../layout/hooks/usePinAnnouncements.ts | 70 ++++++++++++++++ .../rooms/livekit/prefabs/VideoConference.tsx | 4 +- 4 files changed, 82 insertions(+), 81 deletions(-) create mode 100644 src/frontend/src/features/layout/components/PinAnnouncer.tsx create mode 100644 src/frontend/src/features/layout/hooks/usePinAnnouncements.ts diff --git a/src/frontend/src/features/layout/components/PinAnnouncer.tsx b/src/frontend/src/features/layout/components/PinAnnouncer.tsx new file mode 100644 index 00000000..c8d5ffe9 --- /dev/null +++ b/src/frontend/src/features/layout/components/PinAnnouncer.tsx @@ -0,0 +1,6 @@ +import { usePinAnnouncements } from '../hooks/usePinAnnouncements' + +export const PinAnnouncer = () => { + usePinAnnouncements() + return null +} diff --git a/src/frontend/src/features/layout/components/StageLayout.tsx b/src/frontend/src/features/layout/components/StageLayout.tsx index 7938598e..f465568b 100644 --- a/src/frontend/src/features/layout/components/StageLayout.tsx +++ b/src/frontend/src/features/layout/components/StageLayout.tsx @@ -1,8 +1,4 @@ -import { - FocusLayoutContainer, - useRoomContext, - useTracks, -} from '@livekit/components-react' +import { FocusLayoutContainer, useTracks } from '@livekit/components-react' import { CarouselLayout } from '@/features/layout/components/CarouselLayout' import { FocusLayout } from '@/features/layout/components/FocusLayout' import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile' @@ -13,36 +9,15 @@ import { log, type TrackReferenceOrPlaceholder, } from '@livekit/components-core' -import { type Participant, RoomEvent, Track } from 'livekit-client' +import { RoomEvent, Track } from 'livekit-client' import { useSnapshot } from 'valtio' import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout' -import { useCallback, useEffect, useRef } from 'react' -import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' -import { useTranslation } from 'react-i18next' -import { getParticipantName } from '@/features/rooms/utils/getParticipantName' +import { useEffect, useRef } from 'react' export const StageLayout = () => { const lastAutoFocusedScreenShareTrack = useRef(null) - const lastPinnedParticipantIdentityRef = useRef(null) - - const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' }) - const { t: tRooms } = useTranslation('rooms') - - const room = useRoomContext() - const announce = useScreenReaderAnnounce() - - const getAnnouncementName = useCallback( - (participant?: Participant | null) => { - if (!participant) return tRooms('participants.unknown') - return participant.isLocal - ? tRooms('participants.you') - : getParticipantName(participant) - }, - [tRooms] - ) - const tracks = useTracks( [ { source: Track.Source.Camera, withPlaceholder: true }, @@ -61,58 +36,6 @@ export const StageLayout = () => { (track) => !isEqualTrackRef(track, pinnedTrackRef) ) - // handle pin announcements - useEffect(() => { - const participant = pinnedTrackRef?.participant - - // 1. unpin - if (!participant) { - if (!lastPinnedParticipantIdentityRef.current) return - - const lastIdentity = lastPinnedParticipantIdentityRef.current - const lastParticipant = - room.localParticipant.identity === lastIdentity - ? room.localParticipant - : room.remoteParticipants.get(lastIdentity) - const announcementName = getAnnouncementName(lastParticipant) - - announce( - lastParticipant?.isLocal - ? t('self.unpin') - : t('unpin', { - name: announcementName, - }) - ) - - lastPinnedParticipantIdentityRef.current = null - return - } - - // 2. same pin → do nothing - if (lastPinnedParticipantIdentityRef.current === participant.identity) { - return - } - - // 3. new pin - const participantName = participant.isLocal - ? tRooms('participants.you') - : getParticipantName(participant) - - lastPinnedParticipantIdentityRef.current = participant.identity - - announce( - participant.isLocal ? t('self.pin') : t('pin', { name: participantName }) - ) - }, [ - announce, - pinnedTrackRef, - getAnnouncementName, - room.localParticipant, - room.remoteParticipants, - t, - tRooms, - ]) - /* eslint-disable react-hooks/exhaustive-deps */ // Code duplicated from LiveKit; this warning will be addressed in the refactoring. useEffect(() => { diff --git a/src/frontend/src/features/layout/hooks/usePinAnnouncements.ts b/src/frontend/src/features/layout/hooks/usePinAnnouncements.ts new file mode 100644 index 00000000..d43399fd --- /dev/null +++ b/src/frontend/src/features/layout/hooks/usePinAnnouncements.ts @@ -0,0 +1,70 @@ +import { useEffect, useRef, useCallback } from 'react' +import { useRoomContext } from '@livekit/components-react' +import { Participant } from 'livekit-client' +import { useSnapshot } from 'valtio' +import { useTranslation } from 'react-i18next' +import { layoutStore } from '@/stores/layout' +import { getParticipantName } from '@/features/rooms/utils/getParticipantName' +import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' + +export const usePinAnnouncements = () => { + const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' }) + const { t: tRooms } = useTranslation('rooms') + + const room = useRoomContext() + const announce = useScreenReaderAnnounce() + + const { pinnedTrackRef } = useSnapshot(layoutStore) + const lastPinnedIdentityRef = useRef(null) + + const getAnnouncementName = useCallback( + (participant?: Participant | null) => { + if (!participant) return tRooms('participants.unknown') + return participant.isLocal + ? tRooms('participants.you') + : getParticipantName(participant) + }, + [tRooms] + ) + + const pinnedIdentity = pinnedTrackRef?.participant?.identity ?? null + + useEffect(() => { + // 1. unpin + if (!pinnedIdentity) { + const lastIdentity = lastPinnedIdentityRef.current + if (!lastIdentity) return + + const lastParticipant = + room.localParticipant.identity === lastIdentity + ? room.localParticipant + : room.remoteParticipants.get(lastIdentity) + + announce( + lastParticipant?.isLocal + ? t('self.unpin') + : t('unpin', { name: getAnnouncementName(lastParticipant) }) + ) + + lastPinnedIdentityRef.current = null + return + } + + // 2. same pin → do nothing + if (lastPinnedIdentityRef.current === pinnedIdentity) return + + // 3. new pin + const participant = + room.localParticipant.identity === pinnedIdentity + ? room.localParticipant + : room.remoteParticipants.get(pinnedIdentity) + + lastPinnedIdentityRef.current = pinnedIdentity + + announce( + participant?.isLocal + ? t('self.pin') + : t('pin', { name: getAnnouncementName(participant) }) + ) + }, [pinnedIdentity, announce, getAnnouncementName, room, t]) +} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index 4b0dfd87..685d081b 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -24,7 +24,8 @@ import { ReactionPortals } from '@/features/reactions/components/ReactionPortals import { RoomContentArea } from '@/features/layout/components/RoomContentArea' import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture' import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder' -import { StageLayout } from '@/features/layout/components/StageLayout.tsx' +import { StageLayout } from '@/features/layout/components/StageLayout' +import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer' /** * @public @@ -88,6 +89,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) { onClose={() => setIsShareErrorVisible(false)} /> + {isPictureInPictureOpen ? : }