mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-12 16:07:49 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 867a14d0a3 |
@@ -31,6 +31,7 @@ and this project adheres to
|
|||||||
- 🐛(frontend) omit the default speaker id sent to LiveKit
|
- 🐛(frontend) omit the default speaker id sent to LiveKit
|
||||||
- 🐛(frontend) play the speaker test when no output is picked
|
- 🐛(frontend) play the speaker test when no output is picked
|
||||||
- 🐛(frontend) keep the admin panel in place when changing a setting #1804
|
- 🐛(frontend) keep the admin panel in place when changing a setting #1804
|
||||||
|
- 🐛(frontend) promote the active speaker in the carousel layout
|
||||||
|
|
||||||
## [1.34.0] - 2026-10-07
|
## [1.34.0] - 2026-10-07
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
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_HEIGHT = 130
|
||||||
const MIN_WIDTH = 140
|
const MIN_WIDTH = 140
|
||||||
@@ -66,7 +69,20 @@ export function CarouselLayout({
|
|||||||
setPrevTiles(tilesThatFit)
|
setPrevTiles(tilesThatFit)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
const topSpeakerId = useTopSpeaker(tracks)
|
||||||
|
const { head, rest, restSlots } = splitCarouselTiles(
|
||||||
|
tracks,
|
||||||
|
topSpeakerId,
|
||||||
|
maxVisibleTiles
|
||||||
|
)
|
||||||
|
// useVisualStableUpdate needs a page of at least one tile.
|
||||||
|
const stableRest = useVisualStableUpdate(rest, Math.max(restSlots, 1))
|
||||||
|
const sortedTiles = [...head, ...stableRest]
|
||||||
|
|
||||||
|
// When the head fills the carousel, no rest speaker can be brought on
|
||||||
|
// screen, so the trigger ignores them instead of re-rendering for nothing.
|
||||||
|
const onScreen = sortedTiles.slice(0, maxVisibleTiles)
|
||||||
|
useSpeakerPromotionTrigger(onScreen, restSlots > 0 ? tracks : onScreen)
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (asideEl.current) {
|
if (asideEl.current) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
|||||||
|
|
||||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
const { layout } = useGridLayout(gridEl, tracks.length)
|
||||||
const pagination = usePagination(layout.maxTiles, tracks)
|
const pagination = usePagination(layout.maxTiles, tracks)
|
||||||
useSpeakerPromotionTrigger(pagination.tracks)
|
useSpeakerPromotionTrigger(pagination.tracks, tracks)
|
||||||
|
|
||||||
useSwipe(gridEl, {
|
useSwipe(gridEl, {
|
||||||
onLeftSwipe: pagination.nextPage,
|
onLeftSwipe: pagination.nextPage,
|
||||||
|
|||||||
@@ -8,35 +8,42 @@ import { useEffect, useReducer, useRef } from 'react'
|
|||||||
* Tripwire for speaker promotion.
|
* Tripwire for speaker promotion.
|
||||||
*
|
*
|
||||||
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
|
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
|
||||||
* re-render of the host component only when an active speaker has none of
|
* re-render of the host when a speaker has a tile in `allTiles` but none in
|
||||||
* their tiles are within the visible span (`maxVisibleTiles`). That render
|
* `visibleTiles`. That render re-runs `useVisualStableUpdate`, which reads
|
||||||
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
|
* live `participant.isSpeaking` state and performs the actual swap.
|
||||||
* state and performs the actual swap.
|
|
||||||
*
|
*
|
||||||
* Speakers already visible are ignored. Everything else costs zero React work.
|
* 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).
|
||||||
*
|
*
|
||||||
* Requires the parent to NOT re-render on speaker events itself, i.e.
|
* Requires the parent to NOT re-render on speaker events itself, i.e.
|
||||||
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
||||||
*/
|
*/
|
||||||
export function useSpeakerPromotionTrigger(
|
export function useSpeakerPromotionTrigger(
|
||||||
sortedTiles: TrackReferenceOrPlaceholder[]
|
/** Tiles inside the span the layout guarantees to speakers. */
|
||||||
|
visibleTiles: TrackReferenceOrPlaceholder[],
|
||||||
|
/** Every tile the layout shows, visible or not. */
|
||||||
|
allTiles: TrackReferenceOrPlaceholder[]
|
||||||
) {
|
) {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
||||||
|
|
||||||
// Refs so the listener reads current values without re-subscribing
|
// Read by the listener at event time, so it never re-subscribes.
|
||||||
// and without itself being a render dependency.
|
const tilesRef = useRef({ visibleTiles, allTiles })
|
||||||
const tilesRef = useRef(sortedTiles)
|
tilesRef.current = { visibleTiles, allTiles }
|
||||||
tilesRef.current = sortedTiles
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
||||||
const tiles = tilesRef.current
|
const { visibleTiles, allTiles } = tilesRef.current
|
||||||
const hiddenSpeakerExists = speakers.some(
|
const hasTileIn =
|
||||||
(speaker) =>
|
(tiles: TrackReferenceOrPlaceholder[]) => (identity: string) =>
|
||||||
!tiles.some((t) => t.participant.identity === speaker.identity)
|
tiles.some((t) => t.participant.identity === identity)
|
||||||
)
|
const isShown = hasTileIn(allTiles)
|
||||||
if (hiddenSpeakerExists) {
|
const isVisible = hasTileIn(visibleTiles)
|
||||||
|
|
||||||
|
const isHidden = (identity: string) =>
|
||||||
|
isShown(identity) && !isVisible(identity)
|
||||||
|
|
||||||
|
if (speakers.some(({ identity }) => isHidden(identity))) {
|
||||||
forceRender()
|
forceRender()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
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 camera tile in `tracks`: the slot only shows cameras, so a
|
||||||
|
* participant whose camera is pinned away can't hold it through their screen
|
||||||
|
* share. 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 &&
|
||||||
|
t.source === Track.Source.Camera
|
||||||
|
),
|
||||||
|
onChange: setTopSpeakerId,
|
||||||
|
}),
|
||||||
|
[room]
|
||||||
|
)
|
||||||
|
|
||||||
|
return topSpeakerId
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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. When only one tile
|
||||||
|
* fits, the speaker goes first so they are the one on screen. The rest goes
|
||||||
|
* through `useVisualStableUpdate`.
|
||||||
|
*
|
||||||
|
* `restSlots` is how many on-screen slots are left for the rest. It is 0 or
|
||||||
|
* negative when the head fills the carousel.
|
||||||
|
*
|
||||||
|
* The speaker slot is reserved even while it is empty: `useVisualStableUpdate`
|
||||||
|
* treats any change of page size as a relayout and re-sorts everything, so it
|
||||||
|
* 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 order = maxVisibleTiles < 2 ? [speaker, local] : [local, speaker]
|
||||||
|
const head = order.filter((t) => t !== undefined)
|
||||||
|
const rest = tracks.filter((t) => !head.includes(t))
|
||||||
|
|
||||||
|
const reservedSlots = (local ? 1 : 0) + 1
|
||||||
|
const restSlots = maxVisibleTiles - reservedSlots
|
||||||
|
|
||||||
|
return { head, rest, restSlots }
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user