♻️(frontend) vendor pinned track context using a Valtio store

Vendor the pinned track context and its associated hook from
LiveKit, and switch the underlying state management to a Valtio
store.

The LiveKit ContextProvider was injected high in the tree, so any
pinned track change triggered a re-render of a large portion of the
app. With a Valtio store, only the parts of the app that actually
subscribe to the pinned track or need to pin one re-render when
present in the DOM.
This commit is contained in:
lebaudantoine
2026-07-13 18:35:22 +02:00
parent 40e83ca98a
commit 78c9607cb2
8 changed files with 104 additions and 89 deletions
@@ -1,7 +1,5 @@
import {
FocusLayoutContainer,
useMaybeLayoutContext,
usePinnedTracks,
useRoomContext,
useTracks,
} from '@livekit/components-react'
@@ -16,21 +14,19 @@ import {
type TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { type Participant, 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'
export const StageLayout = () => {
const lastAutoFocusedScreenShareTrack =
useRef<TrackReferenceOrPlaceholder | null>(null)
const lastPinnedParticipantIdentityRef = useRef<string | null>(null)
const layoutContext = useMaybeLayoutContext()
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
const { t: tRooms } = useTranslation('rooms')
@@ -59,15 +55,15 @@ export const StageLayout = () => {
.filter(isTrackReference)
.filter((track) => track.publication.source === Track.Source.ScreenShare)
const focusTrack = usePinnedTracks(layoutContext)?.[0]
const { pinnedTrackRef } = useSnapshot(layoutStore)
const carouselTracks = tracks.filter(
(track) => !isEqualTrackRef(track, focusTrack)
(track) => !isEqualTrackRef(track, pinnedTrackRef)
)
// // handle pin announcements
// handle pin announcements
useEffect(() => {
const participant = focusTrack?.participant
const participant = pinnedTrackRef?.participant
// 1. unpin
if (!participant) {
@@ -109,7 +105,7 @@ export const StageLayout = () => {
)
}, [
announce,
focusTrack,
pinnedTrackRef,
getAnnouncementName,
room.localParticipant,
room.remoteParticipants,
@@ -120,9 +116,6 @@ export const StageLayout = () => {
/* eslint-disable react-hooks/exhaustive-deps */
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
useEffect(() => {
if (!layoutContext) return
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
if (
screenShareTracks.some((track) => track.publication.isSubscribed) &&
@@ -131,10 +124,7 @@ export const StageLayout = () => {
log.debug('Auto set screen share focus:', {
newScreenShareTrack: screenShareTracks[0],
})
layoutContext.pin.dispatch?.({
msg: 'set_pin',
trackReference: screenShareTracks[0],
})
setPinnedTrack(screenShareTracks[0])
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
} else if (
lastAutoFocusedScreenShareTrack.current &&
@@ -145,23 +135,20 @@ export const StageLayout = () => {
)
) {
log.debug('Auto clearing screen share focus.')
layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
clearPinnedTrack()
lastAutoFocusedScreenShareTrack.current = null
}
if (focusTrack && !isTrackReference(focusTrack)) {
if (pinnedTrackRef && !isTrackReference(pinnedTrackRef)) {
const updatedFocusTrack = tracks.find(
(tr) =>
tr.participant.identity === focusTrack.participant.identity &&
tr.source === focusTrack.source
tr.participant.identity === pinnedTrackRef.participant.identity &&
tr.source === pinnedTrackRef.source
)
if (
updatedFocusTrack !== focusTrack &&
updatedFocusTrack !== pinnedTrackRef &&
isTrackReference(updatedFocusTrack)
) {
layoutContext.pin.dispatch?.({
msg: 'set_pin',
trackReference: updatedFocusTrack,
})
setPinnedTrack(updatedFocusTrack)
}
}
}, [
@@ -170,15 +157,14 @@ export const StageLayout = () => {
(ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`
)
.join(),
focusTrack?.publication?.trackSid,
pinnedTrackRef?.publication?.trackSid,
tracks,
])
/* eslint-enable react-hooks/exhaustive-deps */
return (
<>
{!focusTrack ? (
{!pinnedTrackRef ? (
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
@@ -195,10 +181,10 @@ export const StageLayout = () => {
>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
{pinnedTrackRef && <FocusLayout trackRef={pinnedTrackRef} />}
</FocusLayoutContainer>
</div>
)}
</>
)
}
}
@@ -4,13 +4,13 @@ import { Button as RACButton } from 'react-aria-components'
import { Track } from 'livekit-client'
import Source = Track.Source
import { useMaybeLayoutContext } from '@livekit/components-react'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { type ToastProps } from './Toast'
import { HStack, styled } from '@/styled-system/jsx'
import { Div } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { StyledToastContainer } from './StyledToastContainer'
import { setPinnedTrack } from '@/stores/layout'
const ClickableToast = styled(RACButton, {
base: {
@@ -28,7 +28,6 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
state,
ref
)
const layoutContext = useMaybeLayoutContext()
const participant = props.toast.content.participant
if (!participant) return
@@ -38,18 +37,13 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
publication: participant.getTrackPublication(Source.Camera),
source: Source.Camera,
}
const pinParticipant = () => {
layoutContext?.pin.dispatch?.({
msg: 'set_pin',
trackReference,
})
}
return (
<StyledToastContainer {...toastProps} ref={ref}>
<ClickableToast
ref={ref}
onPress={(e) => {
pinParticipant()
setPinnedTrack(trackReference)
closeButtonProps.onPress?.(e)
}}
>
@@ -1,15 +1,28 @@
import type { Participant } from 'livekit-client'
import { Participant, Track } from 'livekit-client'
import { menuRecipe } from '@/primitives/menuRecipe'
import { HStack } from '@/styled-system/jsx'
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { useFocusToggleParticipant } from '@/features/rooms/livekit/hooks/useFocusToggleParticipant'
import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import { isEqualTrackRef } from '@livekit/components-core'
import Source = Track.Source
import { useMemo } from 'react'
export const PinMenuItem = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
const { toggle, inFocus } = useFocusToggleParticipant(participant)
const trackRef = useMemo(() => {
return {
participant: participant,
publication: participant.getTrackPublication(Source.Camera),
source: Source.Camera,
}
}, [participant])
const { pinnedTrackRef } = useSnapshot(layoutStore)
const inFocus = isEqualTrackRef(pinnedTrackRef, trackRef)
return (
<MenuItem
@@ -17,7 +30,7 @@ export const PinMenuItem = ({ participant }: { participant: Participant }) => {
name: participant.name,
})}
className={menuRecipe({ icon: true }).item}
onAction={toggle}
onAction={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
>
<HStack gap={0.25}>
{inFocus ? (
@@ -7,7 +7,6 @@ import {
useEnsureTrackRef,
useFeatureContext,
useIsEncrypted,
useMaybeLayoutContext,
useMaybeTrackRefContext,
useParticipantTile,
VideoTrack,
@@ -16,8 +15,8 @@ import {
} from '@livekit/components-react'
import React from 'react'
import {
isEqualTrackRef,
isTrackReference,
isTrackReferencePinned,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { Track } from 'livekit-client'
@@ -34,6 +33,8 @@ import { useTranslation } from 'react-i18next'
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
import { useSnapshot } from 'valtio'
import { layoutStore, clearPinnedTrack } from '@/stores/layout'
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
@@ -72,6 +73,8 @@ export const ParticipantTile: (
}: ParticipantTileExtendedProps,
ref
) {
const { pinnedTrackRef } = useSnapshot(layoutStore)
const trackReference = useEnsureTrackRef(trackRef)
const { elementProps } = useParticipantTile<HTMLDivElement>({
@@ -81,8 +84,6 @@ export const ParticipantTile: (
trackRef: trackReference,
})
const isEncrypted = useIsEncrypted(trackReference.participant)
const layoutContext = useMaybeLayoutContext()
const autoManageSubscription = useFeatureContext()?.autoSubscription
const handleSubscribe = React.useCallback(
@@ -90,14 +91,13 @@ export const ParticipantTile: (
if (
trackReference.source &&
!subscribed &&
layoutContext &&
layoutContext.pin.dispatch &&
isTrackReferencePinned(trackReference, layoutContext.pin.state)
pinnedTrackRef &&
isEqualTrackRef(trackReference, pinnedTrackRef)
) {
layoutContext.pin.dispatch({ msg: 'clear_pin' })
clearPinnedTrack()
}
},
[trackReference, layoutContext]
[trackReference, pinnedTrackRef]
)
const { isHandRaised } = useRaisedHand({
@@ -9,12 +9,12 @@ import {
RiPushpin2Line,
RiUnpinLine,
} from '@remixicon/react'
import {
useFocusToggle,
useTrackMutedIndicator,
} from '@livekit/components-react'
import { useTrackMutedIndicator } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import {
isEqualTrackRef,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { useEffect, useRef, useState } from 'react'
import { useSidePanel } from '../hooks/useSidePanel'
import { useFullScreen } from '../hooks/useFullScreen'
@@ -22,6 +22,8 @@ import { type Participant, Track } from 'livekit-client'
import { MuteAlertDialog } from './MuteAlertDialog'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { useSnapshot } from 'valtio'
import { layoutStore, setPinnedTrack, clearPinnedTrack } from '@/stores/layout'
const ZoomButton = ({
trackRef,
@@ -56,18 +58,17 @@ const FocusButton = ({
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { mergedProps, inFocus } = useFocusToggle({
trackRef,
props: {},
})
const { pinnedTrackRef } = useSnapshot(layoutStore)
const inFocus = isEqualTrackRef(trackRef, pinnedTrackRef)
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onPress={(event) => mergedProps?.onClick?.(event as any)}
onPress={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
>
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
</Button>
@@ -1,10 +1,33 @@
import { useFocusToggleParticipant } from '@/features/rooms/livekit/hooks/useFocusToggleParticipant'
import type { Participant } from 'livekit-client'
import { Participant, Track } from 'livekit-client'
import { RiPushpin2Fill } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { useMemo } from 'react'
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { isEqualTrackRef } from '@livekit/components-core'
import Source = Track.Source
export const PinBadge = ({ participant }: { participant: Participant }) => {
const { inFocus } = useFocusToggleParticipant(participant)
const cameraTrackRef = useMemo(() => {
return {
participant: participant,
publication: participant.getTrackPublication(Source.Camera),
source: Source.Camera,
}
}, [participant])
const screenShareTrackRef = useMemo(() => {
return {
participant: participant,
publication: participant.getTrackPublication(Source.ScreenShare),
source: Source.ScreenShare,
}
}, [participant])
const { pinnedTrackRef } = useSnapshot(layoutStore)
const inFocus =
isEqualTrackRef(pinnedTrackRef, cameraTrackRef) ||
isEqualTrackRef(pinnedTrackRef, screenShareTrackRef)
if (!inFocus) return
@@ -1,14 +1,9 @@
import {
isWeb,
} from '@livekit/components-core'
import { isWeb } from '@livekit/components-core'
import { Track } from 'livekit-client'
import React, { useCallback, useState } from 'react'
import {
ConnectionStateToast,
LayoutContextProvider,
RoomAudioRenderer,
useCreateLayoutContext,
} from '@livekit/components-react'
import { ControlBar } from './ControlBar/ControlBar'
@@ -58,7 +53,6 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
* @public
*/
export function VideoConference({ ...props }: VideoConferenceProps) {
const { toggleSettingsDialog } = useSettingsDialog()
useConnectionObserver()
@@ -73,8 +67,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
}, [toggleSettingsDialog]),
})
const layoutContext = useCreateLayoutContext()
useNoiseReduction()
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
@@ -90,21 +82,14 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
}}
>
{isWeb() && (
<LayoutContextProvider
value={layoutContext}
// onPinChange={handleFocusStateChange}
>
<>
<ScreenShareErrorModal
isOpen={isShareErrorVisible}
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<RoomContentArea>
{isPictureInPictureOpen ? (
<PipRoomPlaceholder />
) : (
<StageLayout />
)}
{isPictureInPictureOpen ? <PipRoomPlaceholder /> : <StageLayout />}
</RoomContentArea>
<ControlBar
onDeviceError={(e) => {
@@ -119,7 +104,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
}}
/>
<SidePanel />
</LayoutContextProvider>
</>
)}
<RoomAudioRenderer />
<ConnectionStateToast />
+14 -1
View File
@@ -1,8 +1,11 @@
import { proxy } from 'valtio'
import { proxy, ref } from 'valtio'
import type {
PanelId,
SubPanelId,
} from '@/features/rooms/livekit/hooks/useSidePanel'
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
type PinnedTrackRef = ReturnType<typeof ref<TrackReferenceOrPlaceholder>>
type State = {
showHeader: boolean
@@ -11,6 +14,7 @@ type State = {
activePanelId: PanelId | null
activeSubPanelId: SubPanelId | null
showReactionsToolbar: boolean
pinnedTrackRef?: PinnedTrackRef
}
export const layoutStore = proxy<State>({
@@ -20,4 +24,13 @@ export const layoutStore = proxy<State>({
activePanelId: null,
activeSubPanelId: null,
showReactionsToolbar: false,
pinnedTrackRef: undefined,
})
export const setPinnedTrack = (trackRef: TrackReferenceOrPlaceholder): void => {
layoutStore.pinnedTrackRef = ref(trackRef)
}
export const clearPinnedTrack = (): void => {
layoutStore.pinnedTrackRef = undefined
}