mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdb5d447b0 |
+4
-6
@@ -8,16 +8,15 @@ 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
|
||||
@@ -60,7 +59,6 @@ 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
|
||||
|
||||
|
||||
@@ -402,14 +402,7 @@ class RecordingAdmin(admin.ModelAdmin):
|
||||
"""Recording admin interface declaration."""
|
||||
|
||||
inlines = (RecordingAccessInline,)
|
||||
search_fields = [
|
||||
"status",
|
||||
"=id",
|
||||
"worker_id",
|
||||
"room__slug",
|
||||
"=room__id",
|
||||
"accesses__user__email",
|
||||
]
|
||||
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
||||
list_display = (
|
||||
"id",
|
||||
"status",
|
||||
|
||||
@@ -215,5 +215,6 @@ class RoomViewSet(
|
||||
"client_id": client_id,
|
||||
"external_api": True,
|
||||
"auth_method": auth_method,
|
||||
"$set": {"email": self.request.user.email},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -26,7 +26,6 @@ export const PipFocusLayout = memo(
|
||||
<ParticipantTile
|
||||
key={getTrackKey(mainTrack)}
|
||||
trackRef={mainTrack}
|
||||
disableTileControls
|
||||
/>
|
||||
</MainSlot>
|
||||
)}
|
||||
@@ -35,7 +34,6 @@ export const PipFocusLayout = memo(
|
||||
<ParticipantTile
|
||||
key={getTrackKey(thumbnailTrack)}
|
||||
trackRef={thumbnailTrack}
|
||||
disableTileControls
|
||||
/>
|
||||
</Thumbnail>
|
||||
)}
|
||||
@@ -50,10 +48,9 @@ const FocusContainer = styled('div', {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -61,8 +58,6 @@ const MainSlot = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
'& .lk-participant-media-video': {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
@@ -72,13 +67,13 @@ const MainSlot = styled('div', {
|
||||
const Thumbnail = styled('div', {
|
||||
base: {
|
||||
position: 'absolute',
|
||||
right: '1.25rem',
|
||||
bottom: '1.25rem',
|
||||
right: '1rem',
|
||||
bottom: '1rem',
|
||||
width: '42%',
|
||||
maxWidth: '220px',
|
||||
minWidth: '140px',
|
||||
aspectRatio: '16 / 9',
|
||||
borderRadius: '8px',
|
||||
borderRadius: '4px',
|
||||
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} disableTileControls />
|
||||
<ParticipantTile trackRef={track} />
|
||||
</GridCell>
|
||||
))}
|
||||
</GridContainer>
|
||||
@@ -54,8 +54,7 @@ const GridContainer = styled('div', {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'grid',
|
||||
gap: '0.5rem',
|
||||
boxSizing: 'border-box',
|
||||
gap: '0.25rem',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -64,7 +63,7 @@ const GridCell = styled('div', {
|
||||
position: 'relative',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
borderRadius: '8px',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
// Paint on own layer so FLIP transforms don't trigger layout thrash.
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
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,11 +1,10 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { 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 {
|
||||
@@ -14,12 +13,9 @@ import {
|
||||
} from '@livekit/components-core'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export const PipStage = () => {
|
||||
const tracks = useTracks(
|
||||
@@ -45,55 +41,51 @@ export const PipStage = () => {
|
||||
[tracks]
|
||||
)
|
||||
|
||||
// Cap camera tiles in screen-share mode. Called unconditionally for hook rules.
|
||||
const paginatedCameraTracks = usePagination(MAX_PIP_TILES - 1, cameraTracks)
|
||||
// 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 the grid at MAX_PIP_TILES per page for the non-screenshare grid mode.
|
||||
const pagination = usePagination(MAX_PIP_TILES, 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)
|
||||
|
||||
if (tracks.length === 0) return null
|
||||
|
||||
// Screen share active
|
||||
if (screenShareTrack) {
|
||||
// Solo presenter: screen share fills the area, camera as small thumbnail
|
||||
if (cameraTracks.length <= 1) {
|
||||
return (
|
||||
/**
|
||||
* 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>
|
||||
<StageFrame>
|
||||
<PipFocusLayout
|
||||
mainTrack={screenShareTrack}
|
||||
thumbnailTrack={cameraTracks[0]}
|
||||
/>
|
||||
<PipGridLayout tracks={pagination.tracks} />
|
||||
</StageFrame>
|
||||
)
|
||||
}
|
||||
// Multiple cameras: camera row at top, screen share below
|
||||
return (
|
||||
<PaginatedStage pagination={paginatedCameraTracks}>
|
||||
<PipScreenShareLayout
|
||||
screenShareTrack={screenShareTrack}
|
||||
cameraTracks={paginatedCameraTracks.tracks}
|
||||
<PipPagination
|
||||
totalPageCount={pagination.totalPageCount}
|
||||
currentPage={pagination.currentPage}
|
||||
nextPage={pagination.nextPage}
|
||||
prevPage={pagination.prevPage}
|
||||
/>
|
||||
</PaginatedStage>
|
||||
</StageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 = remoteCameraTrack ?? localCameraTrack
|
||||
|
||||
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? localCameraTrack
|
||||
|
||||
const thumbnailTrack =
|
||||
mainTrack === localCameraTrack ? undefined : localCameraTrack
|
||||
|
||||
@@ -104,31 +96,6 @@ 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,
|
||||
padding: '0.5rem',
|
||||
boxSizing: 'border-box',
|
||||
marginLeft: '0.5rem',
|
||||
marginRight: '0.5rem',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ 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'
|
||||
@@ -11,6 +10,7 @@ const StyledParticipantPlaceHolder = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
@@ -26,10 +26,6 @@ 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)
|
||||
@@ -43,13 +39,7 @@ export const ParticipantPlaceholder = ({
|
||||
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
|
||||
|
||||
return (
|
||||
<StyledParticipantPlaceHolder
|
||||
ref={placeholderEl}
|
||||
style={{
|
||||
backgroundColor: participantColor,
|
||||
backgroundImage: backgroundGradient,
|
||||
}}
|
||||
>
|
||||
<StyledParticipantPlaceHolder ref={placeholderEl}>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '50%',
|
||||
|
||||
@@ -52,7 +52,6 @@ export function TrackRefContextIfNeeded(
|
||||
|
||||
interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
||||
disableMetadata?: boolean
|
||||
disableTileControls?: boolean
|
||||
}
|
||||
|
||||
export const ParticipantTile: (
|
||||
@@ -67,7 +66,6 @@ export const ParticipantTile: (
|
||||
onParticipantClick,
|
||||
disableSpeakingIndicator,
|
||||
disableMetadata,
|
||||
disableTileControls,
|
||||
...htmlProps
|
||||
}: ParticipantTileExtendedProps,
|
||||
ref
|
||||
@@ -233,7 +231,7 @@ export const ParticipantTile: (
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!disableMetadata && !disableTileControls && (
|
||||
{!disableMetadata && (
|
||||
<ParticipantTileFocus
|
||||
trackRef={trackReference}
|
||||
hasKeyboardFocus={hasKeyboardFocus}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* 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%)`
|
||||
Reference in New Issue
Block a user