Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine d5c2d1187d 🔖(minor) bump release to 1.35.0 2026-10-13 09:22:11 +02:00
19 changed files with 35 additions and 199 deletions
+2 -1
View File
@@ -8,6 +8,8 @@ and this project adheres to
## [Unreleased]
## [1.35.0] - 2026-10-13
### Added
- ✨(frontend) let users pick the recording encoding
@@ -31,7 +33,6 @@ and this project adheres to
- 🐛(frontend) omit the default speaker id sent to LiveKit
- 🐛(frontend) play the speaker test when no output is picked
- 🐛(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 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.34.0"
version = "1.35.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.7.0",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.34.0"
version = "1.35.0"
source = { virtual = "." }
dependencies = [
{ name = "boto3" },
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.34.0"
version = "1.35.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1297,7 +1297,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.34.0"
version = "1.35.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.34.0",
"version": "1.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.34.0",
"version": "1.35.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.34.0",
"version": "1.35.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -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,20 +66,7 @@ export function CarouselLayout({
setPrevTiles(tilesThatFit)
}
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)
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,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)
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.34.0",
"version": "1.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.34.0",
"version": "1.35.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.6.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.34.0",
"version": "1.35.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.34.0",
"version": "1.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.34.0",
"version": "1.35.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.34.0",
"version": "1.35.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.34.0"
version = "1.35.0"
requires-python = ">=3.13"
dependencies = [
"fastapi[standard]>=0.105.0",
+1 -1
View File
@@ -1484,7 +1484,7 @@ wheels = [
[[package]]
name = "summary"
version = "1.34.0"
version = "1.35.0"
source = { editable = "." }
dependencies = [
{ name = "boto3" },