Compare commits

..

5 Commits

Author SHA1 Message Date
Cyril 9b3c4d6fce (frontend) add participant color gradient when camera is off
use participant color radial gradient for video-off placeholder
2026-07-10 10:41:55 +02:00
lebaudantoine 0c81d29ee7 (backend) allow searching the recording admin table by owner email
Add the owner's email to the searchable fields of the recording
admin table. This makes it much more convenient for the support team
to look up recordings by user.
2026-07-10 10:15:57 +02:00
Cyril 839a8f1c71 💄(frontend) add consistent spacing to picture-in-picture tiles
Add padding to StageFrame and bigger gaps so PiP tiles aren't flush with window.
2026-07-10 08:45:53 +02:00
Cyril 40ef420cda (frontend) use focus layout for solo PiP screen sharing
Solo cam in screen share PiP is now a BR thumbnail over shared screen
2026-07-10 08:45:51 +02:00
Cyril d995bd9041 (frontend) prioritize screen share in picture-in-picture layout
Show screen share large with cameras in a top row; match main room focus.
2026-07-10 08:45:51 +02:00
11 changed files with 219 additions and 51 deletions
+6 -4
View File
@@ -8,15 +8,16 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(backend) allow searching the recording admin table by owner email
- ✨(frontend) add participant color gradient when camera is off #1490
### Changed
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
### Fixed
- 🩹(backend) identify externally provisioned users to PostHog
## [1.23.0] - 2026-07-08
### Added
@@ -59,6 +60,7 @@ and this project adheres to
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
- 🩹(frontend) enable screen share button in PiP #1458
- 🐛(backend) support unencoded S3 notification object keys #1455
- ✨(frontend) prioritize screen share in picture-in-picture layout #1467
### Changed
+8 -1
View File
@@ -402,7 +402,14 @@ class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
inlines = (RecordingAccessInline,)
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
search_fields = [
"status",
"=id",
"worker_id",
"room__slug",
"=room__id",
"accesses__user__email",
]
list_display = (
"id",
"status",
@@ -215,6 +215,5 @@ class RoomViewSet(
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
"$set": {"email": self.request.user.email},
},
)
@@ -26,6 +26,7 @@ export const PipFocusLayout = memo(
<ParticipantTile
key={getTrackKey(mainTrack)}
trackRef={mainTrack}
disableTileControls
/>
</MainSlot>
)}
@@ -34,6 +35,7 @@ export const PipFocusLayout = memo(
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
disableTileControls
/>
</Thumbnail>
)}
@@ -48,9 +50,10 @@ const FocusContainer = styled('div', {
position: 'relative',
width: '100%',
height: '100%',
borderRadius: '4px',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
boxSizing: 'border-box',
},
})
@@ -58,6 +61,8 @@ const MainSlot = styled('div', {
base: {
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
'& .lk-participant-media-video': {
objectFit: 'contain',
},
@@ -67,13 +72,13 @@ const MainSlot = styled('div', {
const Thumbnail = styled('div', {
base: {
position: 'absolute',
right: '1rem',
bottom: '1rem',
right: '1.25rem',
bottom: '1.25rem',
width: '42%',
maxWidth: '220px',
minWidth: '140px',
aspectRatio: '16 / 9',
borderRadius: '4px',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: 'md',
zIndex: 2,
@@ -41,7 +41,7 @@ export const PipGridLayout = memo(({ tracks }: PipGridLayoutProps) => {
<GridContainer ref={containerRef} style={gridStyle}>
{tracks.map((track, index) => (
<GridCell key={getTrackKey(track)} style={placements[index]}>
<ParticipantTile trackRef={track} />
<ParticipantTile trackRef={track} disableTileControls />
</GridCell>
))}
</GridContainer>
@@ -54,7 +54,8 @@ const GridContainer = styled('div', {
width: '100%',
height: '100%',
display: 'grid',
gap: '0.25rem',
gap: '0.5rem',
boxSizing: 'border-box',
},
})
@@ -63,7 +64,7 @@ const GridCell = styled('div', {
position: 'relative',
minWidth: 0,
minHeight: 0,
borderRadius: '4px',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
// Paint on own layer so FLIP transforms don't trigger layout thrash.
@@ -0,0 +1,97 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipScreenShareLayoutProps = {
screenShareTrack: TrackReferenceOrPlaceholder
cameraTracks: TrackReferenceOrPlaceholder[]
}
/**
* Layout when a screen share is active.
* Camera tiles are shown as a compact row at the top; the screen share occupies
* the remaining space below, much larger than the camera tiles.
*/
export const PipScreenShareLayout = memo(
({ screenShareTrack, cameraTracks }: PipScreenShareLayoutProps) => {
return (
<LayoutContainer>
{cameraTracks.length > 0 && (
<CameraTilesRow>
{cameraTracks.map((track) => (
<CameraTile key={getTrackKey(track)}>
<ParticipantTile trackRef={track} disableTileControls />
</CameraTile>
))}
</CameraTilesRow>
)}
<ScreenShareSlot>
<ParticipantTile trackRef={screenShareTrack} disableTileControls />
</ScreenShareSlot>
</LayoutContainer>
)
}
)
PipScreenShareLayout.displayName = 'PipScreenShareLayout'
const LayoutContainer = styled('div', {
base: {
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
boxSizing: 'border-box',
overflow: 'hidden',
},
})
const CameraTilesRow = styled('div', {
base: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
gap: '0.5rem',
flexShrink: 0,
height: '22%',
minHeight: '60px',
maxHeight: '120px',
},
})
const CameraTile = styled('div', {
base: {
position: 'relative',
flex: '0 1 auto',
height: '100%',
aspectRatio: '16 / 9',
minWidth: 0,
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
},
})
const ScreenShareSlot = styled('div', {
base: {
position: 'relative',
flex: 1,
minHeight: 0,
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
@@ -1,10 +1,11 @@
import { useMemo } from 'react'
import React, { useMemo } from 'react'
import { usePagination, useTracks } from '@livekit/components-react'
import { RoomEvent, Track } from 'livekit-client'
import { styled } from '@/styled-system/jsx'
import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout'
import { PipPagination } from './PipPagination'
import { PipScreenShareLayout } from './PipScreenShareLayout'
import { StageFrame } from './StageFrame'
import { MAX_PIP_TILES } from '../../utils/pipGrid'
import {
@@ -13,9 +14,12 @@ import {
} from '@livekit/components-core'
/**
* PipStage picks between two layouts based on track count:
* - Focus mode (≤ 2 tracks): one main track + one thumbnail overlay.
* - Grid mode (3+ tracks): adaptive tiling.
* PipStage picks between three layouts:
* - Screen share mode (any screen share active):
* small camera tiles in a row at the top, large screen share below.
* - Grid mode (3+ camera tracks, no screen share): adaptive tiling.
* - Focus mode (≤ 2 camera tracks, no screen share): one main track
* + one thumbnail overlay.
*/
export const PipStage = () => {
const tracks = useTracks(
@@ -41,51 +45,55 @@ export const PipStage = () => {
[tracks]
)
// Grid mode order: screen share leads, then cameras (already ordered by
// active speaker via the `ActiveSpeakersChanged` update above).
const gridTracks = useMemo(
() =>
screenShareTrack ? [screenShareTrack, ...cameraTracks] : cameraTracks,
[screenShareTrack, cameraTracks]
)
// Cap camera tiles in screen-share mode. Called unconditionally for hook rules.
const paginatedCameraTracks = usePagination(MAX_PIP_TILES - 1, cameraTracks)
// Cap the grid at MAX_PIP_TILES per page. `usePagination` keeps the visible
// page visually stable (active/recent speakers stay put) via its internal
// `useVisualStableUpdate`. Called unconditionally to respect hook rules.
const pagination = usePagination(MAX_PIP_TILES, gridTracks)
// Cap the grid at MAX_PIP_TILES per page for the non-screenshare grid mode.
const pagination = usePagination(MAX_PIP_TILES, cameraTracks)
if (tracks.length === 0) return null
/**
* The focus layout shows one main track with one thumbnail overlay,
* so it can only fit 2 tracks. Beyond that we switch to the grid.
*/
if (gridTracks.length > 2) {
return (
<StageWrapper>
// Screen share active
if (screenShareTrack) {
// Solo presenter: screen share fills the area, camera as small thumbnail
if (cameraTracks.length <= 1) {
return (
<StageFrame>
<PipGridLayout tracks={pagination.tracks} />
<PipFocusLayout
mainTrack={screenShareTrack}
thumbnailTrack={cameraTracks[0]}
/>
</StageFrame>
<PipPagination
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
nextPage={pagination.nextPage}
prevPage={pagination.prevPage}
)
}
// Multiple cameras: camera row at top, screen share below
return (
<PaginatedStage pagination={paginatedCameraTracks}>
<PipScreenShareLayout
screenShareTrack={screenShareTrack}
cameraTracks={paginatedCameraTracks.tracks}
/>
</StageWrapper>
</PaginatedStage>
)
}
// 3+ camera tracks → adaptive grid
if (cameraTracks.length > 2) {
return (
<PaginatedStage pagination={pagination}>
<PipGridLayout tracks={pagination.tracks} />
</PaginatedStage>
)
}
// ≤ 2 camera tracks → focus layout (main + optional thumbnail)
const localCameraTrack = cameraTracks.find(
(track) => track.participant?.isLocal
)
const remoteCameraTrack = cameraTracks.find(
(track) => !track.participant?.isLocal
)
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? localCameraTrack
const mainTrack = remoteCameraTrack ?? localCameraTrack
const thumbnailTrack =
mainTrack === localCameraTrack ? undefined : localCameraTrack
@@ -96,6 +104,31 @@ export const PipStage = () => {
)
}
type PaginationResult = {
totalPageCount: number
currentPage: number
nextPage: () => void
prevPage: () => void
}
const PaginatedStage = ({
pagination,
children,
}: {
pagination: PaginationResult
children: React.ReactNode
}) => (
<StageWrapper>
<StageFrame>{children}</StageFrame>
<PipPagination
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
nextPage={pagination.nextPage}
prevPage={pagination.prevPage}
/>
</StageWrapper>
)
const StageWrapper = styled('div', {
base: {
display: 'flex',
@@ -25,8 +25,8 @@ const Container = styled('div', {
flex: 1,
minWidth: 0,
minHeight: 0,
marginLeft: '0.5rem',
marginRight: '0.5rem',
padding: '0.5rem',
boxSizing: 'border-box',
borderRadius: '4px',
overflow: 'hidden',
},
@@ -2,6 +2,7 @@ import type { Participant } from 'livekit-client'
import { styled } from '@/styled-system/jsx'
import { Avatar } from '@/components/Avatar'
import { useIsSpeaking } from '@livekit/components-react'
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useMemo, useRef } from 'react'
@@ -10,7 +11,6 @@ const StyledParticipantPlaceHolder = styled('div', {
base: {
width: '100%',
height: '100%',
backgroundColor: 'primaryDark.100',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
@@ -26,6 +26,10 @@ export const ParticipantPlaceholder = ({
}: ParticipantPlaceholderProps) => {
const isSpeaking = useIsSpeaking(participant)
const participantColor = getParticipantColor(participant)
const backgroundGradient = useMemo(
() => getParticipantBackgroundGradient(participantColor),
[participantColor]
)
const placeholderEl = useRef<HTMLDivElement>(null)
const { width, height } = useSize(placeholderEl)
@@ -39,7 +43,13 @@ export const ParticipantPlaceholder = ({
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
return (
<StyledParticipantPlaceHolder ref={placeholderEl}>
<StyledParticipantPlaceHolder
ref={placeholderEl}
style={{
backgroundColor: participantColor,
backgroundImage: backgroundGradient,
}}
>
<div
style={{
borderRadius: '50%',
@@ -52,6 +52,7 @@ export function TrackRefContextIfNeeded(
interface ParticipantTileExtendedProps extends ParticipantTileProps {
disableMetadata?: boolean
disableTileControls?: boolean
}
export const ParticipantTile: (
@@ -66,6 +67,7 @@ export const ParticipantTile: (
onParticipantClick,
disableSpeakingIndicator,
disableMetadata,
disableTileControls,
...htmlProps
}: ParticipantTileExtendedProps,
ref
@@ -231,7 +233,7 @@ export const ParticipantTile: (
)}
</>
)}
{!disableMetadata && (
{!disableMetadata && !disableTileControls && (
<ParticipantTileFocus
trackRef={trackReference}
hasKeyboardFocus={hasKeyboardFocus}
@@ -0,0 +1,12 @@
/**
* Builds a radial gradient from the participant's avatar color:
* brighter in the center (where the avatar sits), darker at the edges.
* Mixed in oklch so the color stays vivid as it darkens instead of greying out.
*/
export const getParticipantBackgroundGradient = (color: string): string =>
`radial-gradient(circle at 50% 45%,
color-mix(in oklch, ${color} 82%, white) 0%,
color-mix(in oklch, ${color} 90%, white) 8%,
${color} 35%,
color-mix(in oklch, ${color} 85%, black) 65%,
color-mix(in oklch, ${color} 65%, black) 100%)`