mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-12 07:57:50 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 521e72d1cc |
@@ -22,9 +22,6 @@
|
|||||||
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
|
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
|
||||||
extra-path=gitlint/
|
extra-path=gitlint/
|
||||||
|
|
||||||
# Use "search" semantics for regexes (gitlint's future default)
|
|
||||||
regex-style-search=True
|
|
||||||
|
|
||||||
# [title-max-length]
|
# [title-max-length]
|
||||||
# line-length=80
|
# line-length=80
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ 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,9 +3,6 @@ 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
|
||||||
@@ -69,20 +66,7 @@ export function CarouselLayout({
|
|||||||
setPrevTiles(tilesThatFit)
|
setPrevTiles(tilesThatFit)
|
||||||
}
|
}
|
||||||
|
|
||||||
const topSpeakerId = useTopSpeaker(tracks)
|
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
||||||
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, tracks)
|
useSpeakerPromotionTrigger(pagination.tracks)
|
||||||
|
|
||||||
useSwipe(gridEl, {
|
useSwipe(gridEl, {
|
||||||
onLeftSwipe: pagination.nextPage,
|
onLeftSwipe: pagination.nextPage,
|
||||||
|
|||||||
@@ -8,42 +8,35 @@ 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 when a speaker has a tile in `allTiles` but none in
|
* re-render of the host component only when an active speaker has none of
|
||||||
* `visibleTiles`. That render re-runs `useVisualStableUpdate`, which reads
|
* their tiles are within the visible span (`maxVisibleTiles`). That render
|
||||||
* live `participant.isSpeaking` state and performs the actual swap.
|
* 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
|
* Speakers already visible are ignored. Everything else costs zero React work.
|
||||||
* 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(
|
||||||
/** Tiles inside the span the layout guarantees to speakers. */
|
sortedTiles: TrackReferenceOrPlaceholder[]
|
||||||
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)
|
||||||
|
|
||||||
// Read by the listener at event time, so it never re-subscribes.
|
// Refs so the listener reads current values without re-subscribing
|
||||||
const tilesRef = useRef({ visibleTiles, allTiles })
|
// and without itself being a render dependency.
|
||||||
tilesRef.current = { visibleTiles, allTiles }
|
const tilesRef = useRef(sortedTiles)
|
||||||
|
tilesRef.current = sortedTiles
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
||||||
const { visibleTiles, allTiles } = tilesRef.current
|
const tiles = tilesRef.current
|
||||||
const hasTileIn =
|
const hiddenSpeakerExists = speakers.some(
|
||||||
(tiles: TrackReferenceOrPlaceholder[]) => (identity: string) =>
|
(speaker) =>
|
||||||
tiles.some((t) => t.participant.identity === identity)
|
!tiles.some((t) => t.participant.identity === speaker.identity)
|
||||||
const isShown = hasTileIn(allTiles)
|
)
|
||||||
const isVisible = hasTileIn(visibleTiles)
|
if (hiddenSpeakerExists) {
|
||||||
|
|
||||||
const isHidden = (identity: string) =>
|
|
||||||
isShown(identity) && !isVisible(identity)
|
|
||||||
|
|
||||||
if (speakers.some(({ identity }) => isHidden(identity))) {
|
|
||||||
forceRender()
|
forceRender()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,39 +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. 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 }
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user