Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot] 8bc3cc9a44 ⬆️(dependencies) update GitHub Actions dependencies 2026-10-12 02:34:59 +00:00
8 changed files with 31 additions and 197 deletions
+13 -13
View File
@@ -87,7 +87,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
@@ -117,11 +117,11 @@ jobs:
timeout 30 sh -c 'until docker exec garage /garage health; do sleep 1; done' timeout 30 sh -c 'until docker exec garage /garage health; do sleep 1; done'
- name: Install Python - name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: "3.13" python-version: "3.14"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
- name: Install the dependencies - name: Install the dependencies
run: uv sync --locked --all-extras run: uv sync --locked --all-extras
@@ -160,7 +160,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
@@ -170,12 +170,12 @@ jobs:
sudo apt-get install -y ffmpeg sudo apt-get install -y ffmpeg
- name: Install Python - name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with: with:
python-version: "3.13" python-version: "3.14"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
- name: Install the project - name: Install the project
run: uv sync --locked --all-extras run: uv sync --locked --all-extras
@@ -189,7 +189,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
@@ -208,12 +208,12 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: "24" node-version: "24"
@@ -232,7 +232,7 @@ jobs:
working-directory: src/sdk/library working-directory: src/sdk/library
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
@@ -255,7 +255,7 @@ jobs:
working-directory: src/sdk/library working-directory: src/sdk/library
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
-1
View File
@@ -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)
}
}