mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-11 15:50:47 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a72efe8c1e | |||
| 91fbb96eb6 |
@@ -18,7 +18,6 @@ and this project adheres to
|
||||
- ⚡️(backend) reduce domain queries on the application token endpoint
|
||||
- ♻️️️(backend) use a dedicated auth scheme for LiveKit token auth
|
||||
- ♻️(all) stop relying on cookies for the lobby flow
|
||||
- ♿️(frontend) show a visible focus outline on menu items #1797
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -28,7 +27,6 @@ and this project adheres to
|
||||
- 🔒️(backend) sign lobby participant id
|
||||
- 🐛(frontend) omit the default speaker id sent to LiveKit
|
||||
- 🐛(frontend) play the speaker test when no output is picked
|
||||
- 🐛(frontend) promote the active speaker in the carousel layout
|
||||
|
||||
## [1.34.0] - 2026-10-07
|
||||
|
||||
|
||||
@@ -3,9 +3,6 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||
import { useTopSpeaker } from '../hooks/useTopSpeaker'
|
||||
import { splitCarouselTiles } from '../utils/splitCarouselTiles'
|
||||
|
||||
const MIN_HEIGHT = 130
|
||||
const MIN_WIDTH = 140
|
||||
@@ -69,19 +66,7 @@ export function CarouselLayout({
|
||||
setPrevTiles(tilesThatFit)
|
||||
}
|
||||
|
||||
const topSpeakerId = useTopSpeaker(tracks)
|
||||
const { head, rest, restSpan } = splitCarouselTiles(
|
||||
tracks,
|
||||
topSpeakerId,
|
||||
maxVisibleTiles
|
||||
)
|
||||
const stableRest = useVisualStableUpdate(rest, restSpan)
|
||||
const sortedTiles = [...head, ...stableRest]
|
||||
|
||||
useSpeakerPromotionTrigger(
|
||||
sortedTiles.slice(0, head.length + restSpan),
|
||||
tracks
|
||||
)
|
||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (asideEl.current) {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
|
||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
||||
const pagination = usePagination(layout.maxTiles, tracks)
|
||||
useSpeakerPromotionTrigger(pagination.tracks, tracks)
|
||||
useSpeakerPromotionTrigger(pagination.tracks)
|
||||
|
||||
useSwipe(gridEl, {
|
||||
onLeftSwipe: pagination.nextPage,
|
||||
|
||||
@@ -8,42 +8,35 @@ import { useEffect, useReducer, useRef } from 'react'
|
||||
* Tripwire for speaker promotion.
|
||||
*
|
||||
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
|
||||
* re-render of the host when a speaker has a tile in `allTiles` but none in
|
||||
* `visibleTiles`. That render re-runs `useVisualStableUpdate`, which reads
|
||||
* live `participant.isSpeaking` state and performs the actual swap.
|
||||
* re-render of the host component only when an active speaker has none of
|
||||
* their tiles are within the visible span (`maxVisibleTiles`). That render
|
||||
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
|
||||
* state and performs the actual swap.
|
||||
*
|
||||
* Speakers already visible cost nothing, nor do speakers the layout doesn't
|
||||
* show at all (e.g. the pinned participant, rendered by the focus view).
|
||||
* Speakers already visible are ignored. Everything else costs zero React work.
|
||||
*
|
||||
* Requires the parent to NOT re-render on speaker events itself, i.e.
|
||||
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
||||
*/
|
||||
export function useSpeakerPromotionTrigger(
|
||||
/** Tiles inside the span the layout guarantees to speakers. */
|
||||
visibleTiles: TrackReferenceOrPlaceholder[],
|
||||
/** Every tile the layout shows, visible or not. */
|
||||
allTiles: TrackReferenceOrPlaceholder[]
|
||||
sortedTiles: TrackReferenceOrPlaceholder[]
|
||||
) {
|
||||
const room = useRoomContext()
|
||||
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
||||
|
||||
// Read by the listener at event time, so it never re-subscribes.
|
||||
const tilesRef = useRef({ visibleTiles, allTiles })
|
||||
tilesRef.current = { visibleTiles, allTiles }
|
||||
// Refs so the listener reads current values without re-subscribing
|
||||
// and without itself being a render dependency.
|
||||
const tilesRef = useRef(sortedTiles)
|
||||
tilesRef.current = sortedTiles
|
||||
|
||||
useEffect(() => {
|
||||
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
||||
const { visibleTiles, allTiles } = tilesRef.current
|
||||
const hasTileIn =
|
||||
(tiles: TrackReferenceOrPlaceholder[]) => (identity: string) =>
|
||||
tiles.some((t) => t.participant.identity === identity)
|
||||
const isShown = hasTileIn(allTiles)
|
||||
const isVisible = hasTileIn(visibleTiles)
|
||||
|
||||
const isHidden = (identity: string) =>
|
||||
isShown(identity) && !isVisible(identity)
|
||||
|
||||
if (speakers.some(({ identity }) => isHidden(identity))) {
|
||||
const tiles = tilesRef.current
|
||||
const hiddenSpeakerExists = speakers.some(
|
||||
(speaker) =>
|
||||
!tiles.some((t) => t.participant.identity === speaker.identity)
|
||||
)
|
||||
if (hiddenSpeakerExists) {
|
||||
forceRender()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { watchTopSpeaker } from '../utils/watchTopSpeaker'
|
||||
|
||||
/**
|
||||
* Identity of the participant who should hold the top speaker slot, among
|
||||
* those with a tile in `tracks`. See `watchTopSpeaker` for the rules.
|
||||
*
|
||||
* Re-renders the host only when the holder changes. Expects
|
||||
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
||||
*/
|
||||
export function useTopSpeaker(tracks: TrackReferenceOrPlaceholder[]) {
|
||||
const room = useRoomContext()
|
||||
const [topSpeakerId, setTopSpeakerId] = useState<string>()
|
||||
|
||||
// Read by the listener at event time; subscribing once keeps the hold
|
||||
// state alive across renders.
|
||||
const tracksRef = useRef(tracks)
|
||||
tracksRef.current = tracks
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
watchTopSpeaker(room, {
|
||||
isEligible: (identity) =>
|
||||
tracksRef.current.some((t) => t.participant.identity === identity),
|
||||
onChange: setTopSpeakerId,
|
||||
}),
|
||||
[room]
|
||||
)
|
||||
|
||||
return topSpeakerId
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
|
||||
/**
|
||||
* Splits carousel tiles into a fixed head and a rest that is free to reorder.
|
||||
*
|
||||
* Head: the local camera, then the top speaker's camera. The rest goes through
|
||||
* `useVisualStableUpdate`, which only keeps speakers visible within its first
|
||||
* `restSpan` tiles. The head's slots are taken out of that span, so the head
|
||||
* can never push a promoted speaker out of view.
|
||||
*
|
||||
* The speaker slot is reserved even while it is empty: `useVisualStableUpdate`
|
||||
* treats any change of span as a relayout and re-sorts everything, so the span
|
||||
* must not move when the first speaker gets promoted.
|
||||
*/
|
||||
export function splitCarouselTiles(
|
||||
tracks: TrackReferenceOrPlaceholder[],
|
||||
topSpeakerId: string | undefined,
|
||||
maxVisibleTiles: number
|
||||
) {
|
||||
const cameraOf = (match: (t: TrackReferenceOrPlaceholder) => boolean) =>
|
||||
tracks.find((t) => t.source === Track.Source.Camera && match(t))
|
||||
|
||||
const local = cameraOf((t) => t.participant.isLocal)
|
||||
const speaker = topSpeakerId
|
||||
? cameraOf((t) => t.participant.identity === topSpeakerId)
|
||||
: undefined
|
||||
|
||||
const head = [local, speaker].filter((t) => t !== undefined)
|
||||
const rest = tracks.filter((t) => !head.includes(t))
|
||||
|
||||
const reservedSlots = (local ? 1 : 0) + 1
|
||||
const restSpan = Math.max(maxVisibleTiles - reservedSlots, 1)
|
||||
|
||||
return { head, rest, restSpan }
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import type { Room } from 'livekit-client'
|
||||
|
||||
/** Minimum time a speaker keeps the top slot before someone else can take it. */
|
||||
export const TOP_SPEAKER_HOLD_MS = 2000
|
||||
|
||||
type SpeakerSource = Pick<Room, 'on' | 'off' | 'activeSpeakers'>
|
||||
|
||||
/**
|
||||
* Calls `onChange` with the participant who should hold the top speaker slot.
|
||||
*
|
||||
* The candidate is the loudest remote speaker for whom `isEligible` is true
|
||||
* (e.g. who has a tile in the layout). The slot is sticky: silence keeps the
|
||||
* current holder. A new speaker only takes over once the holder has had it for
|
||||
* `holdMs`, and only if they are still the loudest at that point; someone who
|
||||
* stops talking before then never steals it. A holder who is no longer
|
||||
* eligible (left, got pinned) is replaced right away.
|
||||
*
|
||||
* `onChange` only fires on an actual change. Returns an unsubscribe function.
|
||||
*/
|
||||
export function watchTopSpeaker(
|
||||
room: SpeakerSource,
|
||||
{
|
||||
isEligible,
|
||||
onChange,
|
||||
holdMs = TOP_SPEAKER_HOLD_MS,
|
||||
}: {
|
||||
isEligible: (identity: string) => boolean
|
||||
onChange: (identity: string) => void
|
||||
holdMs?: number
|
||||
}
|
||||
) {
|
||||
let holder: string | undefined
|
||||
let holdUntil = 0
|
||||
let retry: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const evaluate = () => {
|
||||
clearTimeout(retry)
|
||||
|
||||
// `activeSpeakers` is sorted loudest first and is always current, which
|
||||
// the deferred retry needs; the event payload would be stale by then.
|
||||
const candidate = room.activeSpeakers.find(
|
||||
(p) => !p.isLocal && isEligible(p.identity)
|
||||
)?.identity
|
||||
if (!candidate || candidate === holder) return
|
||||
|
||||
const wait = holder && isEligible(holder) ? holdUntil - Date.now() : 0
|
||||
if (wait > 0) {
|
||||
retry = setTimeout(evaluate, wait)
|
||||
return
|
||||
}
|
||||
|
||||
holder = candidate
|
||||
holdUntil = Date.now() + holdMs
|
||||
onChange(candidate)
|
||||
}
|
||||
|
||||
room.on(RoomEvent.ActiveSpeakersChanged, evaluate)
|
||||
return () => {
|
||||
room.off(RoomEvent.ActiveSpeakersChanged, evaluate)
|
||||
clearTimeout(retry)
|
||||
}
|
||||
}
|
||||
@@ -25,15 +25,12 @@ export const menuRecipe = sva({
|
||||
'&[data-focused]': {
|
||||
color: 'primary.text',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
outline: 'none!',
|
||||
},
|
||||
'&[data-hovered]': {
|
||||
color: 'primary.text',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
},
|
||||
'&[data-focus-visible]': {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'focusRing',
|
||||
outlineOffset: '2px',
|
||||
outline: 'none!',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -52,10 +49,6 @@ export const menuRecipe = sva({
|
||||
dark: {
|
||||
item: {
|
||||
color: 'white',
|
||||
'&[data-focus-visible]': {
|
||||
outlineColor: 'white!',
|
||||
outlineOffset: '2px!',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user