mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 04:09:26 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdb5d447b0 |
@@ -8,11 +8,6 @@ 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
|
||||
@@ -64,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",
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { FocusLayout } from '@/features/rooms/livekit/components/FocusLayout'
|
||||
import { SecondaryScreenShareStrip } from './SecondaryScreenShareStrip'
|
||||
|
||||
export interface FocusAreaProps {
|
||||
focusTrack: TrackReferenceOrPlaceholder
|
||||
secondaryScreenShareTracks: TrackReferenceOrPlaceholder[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Main focus track with an optional strip of secondary screen shares below.
|
||||
*/
|
||||
export function FocusArea({
|
||||
focusTrack,
|
||||
secondaryScreenShareTracks,
|
||||
}: FocusAreaProps) {
|
||||
return (
|
||||
<FocusColumn>
|
||||
<MainFocusSlot>
|
||||
<FocusLayout trackRef={focusTrack} />
|
||||
</MainFocusSlot>
|
||||
<SecondaryScreenShareStrip tracks={secondaryScreenShareTracks} />
|
||||
</FocusColumn>
|
||||
)
|
||||
}
|
||||
|
||||
const FocusColumn = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
})
|
||||
|
||||
const MainFocusSlot = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
'& .lk-participant-tile': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
'& .lk-participant-media-video': {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { memo } from 'react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { getTrackKey } from '@/features/pip/utils/pipTrackSelection'
|
||||
|
||||
export interface SecondaryScreenShareStripProps {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact horizontal strip for non-focused screen shares.
|
||||
* Each tile keeps pin controls so users can switch focus to any share.
|
||||
*/
|
||||
export const SecondaryScreenShareStrip = memo(
|
||||
({ tracks }: SecondaryScreenShareStripProps) => {
|
||||
if (tracks.length === 0) return null
|
||||
|
||||
return (
|
||||
<StripContainer>
|
||||
{tracks.map((track) => (
|
||||
<ShareTile key={getTrackKey(track)}>
|
||||
<ParticipantTile trackRef={track} />
|
||||
</ShareTile>
|
||||
))}
|
||||
</StripContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
SecondaryScreenShareStrip.displayName = 'SecondaryScreenShareStrip'
|
||||
|
||||
const StripContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '0.5rem',
|
||||
flexShrink: 0,
|
||||
height: '120px',
|
||||
minHeight: '80px',
|
||||
maxHeight: '140px',
|
||||
padding: '0.5rem 0 0',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
})
|
||||
|
||||
const ShareTile = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
flex: '0 0 auto',
|
||||
height: '100%',
|
||||
aspectRatio: '16 / 9',
|
||||
minWidth: 0,
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
'& .lk-participant-tile': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
'& .lk-participant-media-video': {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import {
|
||||
isEqualTrackRef,
|
||||
isTrackReference,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
|
||||
export function getSubscribedScreenShareTracks(
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
): TrackReferenceOrPlaceholder[] {
|
||||
return tracks
|
||||
.filter(isTrackReference)
|
||||
.filter(
|
||||
(track) =>
|
||||
track.publication.source === Track.Source.ScreenShare &&
|
||||
track.publication.isSubscribed
|
||||
)
|
||||
}
|
||||
|
||||
export function getNewScreenShareTracks(
|
||||
screenShareTracks: TrackReferenceOrPlaceholder[],
|
||||
knownTrackSids: Set<string>
|
||||
): TrackReferenceOrPlaceholder[] {
|
||||
return screenShareTracks.filter(
|
||||
(track) =>
|
||||
track.publication.trackSid &&
|
||||
!knownTrackSids.has(track.publication.trackSid)
|
||||
)
|
||||
}
|
||||
|
||||
export function syncKnownScreenShareSids(
|
||||
screenShareTracks: TrackReferenceOrPlaceholder[],
|
||||
knownTrackSids: Set<string>
|
||||
): void {
|
||||
const currentSids = new Set(
|
||||
screenShareTracks
|
||||
.map((track) => track.publication.trackSid)
|
||||
.filter((sid): sid is string => !!sid)
|
||||
)
|
||||
|
||||
screenShareTracks.forEach((track) => {
|
||||
if (track.publication.trackSid) {
|
||||
knownTrackSids.add(track.publication.trackSid)
|
||||
}
|
||||
})
|
||||
|
||||
knownTrackSids.forEach((sid) => {
|
||||
if (!currentSids.has(sid)) {
|
||||
knownTrackSids.delete(sid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getNewestScreenShareTrack(
|
||||
screenShareTracks: TrackReferenceOrPlaceholder[]
|
||||
): TrackReferenceOrPlaceholder | undefined {
|
||||
if (screenShareTracks.length === 0) return undefined
|
||||
return screenShareTracks[screenShareTracks.length - 1]
|
||||
}
|
||||
|
||||
export function splitTracksForFocusLayout(
|
||||
tracks: TrackReferenceOrPlaceholder[],
|
||||
focusTrack: TrackReferenceOrPlaceholder | undefined,
|
||||
screenShareTracks: TrackReferenceOrPlaceholder[]
|
||||
): {
|
||||
carouselTracks: TrackReferenceOrPlaceholder[]
|
||||
secondaryScreenShareTracks: TrackReferenceOrPlaceholder[]
|
||||
} {
|
||||
const hasActiveScreenShares = screenShareTracks.length > 0
|
||||
|
||||
const secondaryScreenShareTracks = screenShareTracks.filter(
|
||||
(track) => !focusTrack || !isEqualTrackRef(track, focusTrack)
|
||||
)
|
||||
|
||||
const carouselTracks = tracks.filter((track) => {
|
||||
if (focusTrack && isEqualTrackRef(track, focusTrack)) return false
|
||||
if (hasActiveScreenShares && track.source === Track.Source.ScreenShare) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return { carouselTracks, secondaryScreenShareTracks }
|
||||
}
|
||||
@@ -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,52 +1,21 @@
|
||||
import {
|
||||
isTrackReferencePinned,
|
||||
} from '@livekit/components-core'
|
||||
import { useFocusToggle, useMaybeLayoutContext } from '@livekit/components-react'
|
||||
import { useFocusToggle } from '@livekit/components-react'
|
||||
import { type Participant, Track } from 'livekit-client'
|
||||
import { useCallback } from 'react'
|
||||
import type { MouseEvent } from 'react'
|
||||
import Source = Track.Source
|
||||
|
||||
const getParticipantPinTrackRef = (participant: Participant) => {
|
||||
const screenSharePublication = participant.getTrackPublication(
|
||||
Source.ScreenShare
|
||||
)
|
||||
const source =
|
||||
participant.isScreenShareEnabled && screenSharePublication
|
||||
? Source.ScreenShare
|
||||
: Source.Camera
|
||||
|
||||
return {
|
||||
participant,
|
||||
publication: participant.getTrackPublication(source),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
export const useFocusToggleParticipant = (participant: Participant) => {
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
const trackRef = getParticipantPinTrackRef(participant)
|
||||
const trackRef = {
|
||||
participant: participant,
|
||||
publication: participant.getTrackPublication(Source.Camera),
|
||||
source: Source.Camera,
|
||||
}
|
||||
|
||||
const { mergedProps, inFocus: isFocusedTrack } = useFocusToggle({
|
||||
const { mergedProps, inFocus } = useFocusToggle({
|
||||
trackRef,
|
||||
props: {},
|
||||
})
|
||||
|
||||
const inFocus =
|
||||
isFocusedTrack ||
|
||||
(!!layoutContext?.pin.state &&
|
||||
[Source.Camera, Source.ScreenShare].some((source) => {
|
||||
const ref = {
|
||||
participant,
|
||||
publication: participant.getTrackPublication(source),
|
||||
source,
|
||||
}
|
||||
return (
|
||||
!!ref.publication &&
|
||||
isTrackReferencePinned(ref, layoutContext.pin.state)
|
||||
)
|
||||
}))
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const syntheticEvent = {
|
||||
preventDefault: () => {},
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ControlBar } from './ControlBar/ControlBar'
|
||||
import { FocusLayout } from '../components/FocusLayout'
|
||||
import { ParticipantTile } from '../components/ParticipantTile'
|
||||
import { SidePanel } from '../components/SidePanel'
|
||||
import { RecordingProvider } from '@/features/recording'
|
||||
@@ -38,16 +39,8 @@ import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
||||
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
||||
import { FocusArea } from '@/features/layout/components/FocusArea'
|
||||
import { GridLayout } from '@/features/layout/components/GridLayout'
|
||||
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
||||
import {
|
||||
getNewScreenShareTracks,
|
||||
getNewestScreenShareTrack,
|
||||
getSubscribedScreenShareTracks,
|
||||
splitTracksForFocusLayout,
|
||||
syncKnownScreenShareSids,
|
||||
} from '@/features/layout/utils/screenShareLayout'
|
||||
import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture'
|
||||
import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder'
|
||||
|
||||
@@ -80,7 +73,6 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
|
||||
export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
const lastAutoFocusedScreenShareTrack =
|
||||
useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||
const knownScreenShareTrackSids = useRef<Set<string>>(new Set())
|
||||
const lastPinnedParticipantIdentityRef = useRef<string | null>(null)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
||||
const { t: tRooms } = useTranslation('rooms')
|
||||
@@ -122,11 +114,14 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
|
||||
useNoiseReduction()
|
||||
|
||||
const screenShareTracks = getSubscribedScreenShareTracks(tracks)
|
||||
const screenShareTracks = tracks
|
||||
.filter(isTrackReference)
|
||||
.filter((track) => track.publication.source === Track.Source.ScreenShare)
|
||||
|
||||
const focusTrack = usePinnedTracks(layoutContext)?.[0]
|
||||
const { carouselTracks, secondaryScreenShareTracks } =
|
||||
splitTracksForFocusLayout(tracks, focusTrack, screenShareTracks)
|
||||
const carouselTracks = tracks.filter(
|
||||
(track) => !isEqualTrackRef(track, focusTrack)
|
||||
)
|
||||
|
||||
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
|
||||
|
||||
@@ -186,48 +181,31 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
||||
useEffect(() => {
|
||||
const newScreenShares = getNewScreenShareTracks(
|
||||
screenShareTracks,
|
||||
knownScreenShareTrackSids.current
|
||||
)
|
||||
syncKnownScreenShareSids(
|
||||
screenShareTracks,
|
||||
knownScreenShareTrackSids.current
|
||||
)
|
||||
|
||||
if (newScreenShares.length > 0) {
|
||||
const newestScreenShare = getNewestScreenShareTrack(newScreenShares)
|
||||
if (newestScreenShare) {
|
||||
log.debug('Auto set screen share focus:', {
|
||||
newScreenShareTrack: newestScreenShare,
|
||||
})
|
||||
layoutContext.pin.dispatch?.({
|
||||
msg: 'set_pin',
|
||||
trackReference: newestScreenShare,
|
||||
})
|
||||
lastAutoFocusedScreenShareTrack.current = newestScreenShare
|
||||
}
|
||||
} else if (
|
||||
focusTrack?.source === Track.Source.ScreenShare &&
|
||||
!screenShareTracks.some((track) => isEqualTrackRef(track, focusTrack))
|
||||
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
||||
if (
|
||||
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
||||
lastAutoFocusedScreenShareTrack.current === null
|
||||
) {
|
||||
const newestRemaining = getNewestScreenShareTrack(screenShareTracks)
|
||||
if (newestRemaining) {
|
||||
log.debug('Auto switching to remaining screen share:', {
|
||||
screenShareTrack: newestRemaining,
|
||||
})
|
||||
layoutContext.pin.dispatch?.({
|
||||
msg: 'set_pin',
|
||||
trackReference: newestRemaining,
|
||||
})
|
||||
lastAutoFocusedScreenShareTrack.current = newestRemaining
|
||||
} else {
|
||||
log.debug('Auto clearing screen share focus.')
|
||||
layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
|
||||
lastAutoFocusedScreenShareTrack.current = null
|
||||
}
|
||||
log.debug('Auto set screen share focus:', {
|
||||
newScreenShareTrack: screenShareTracks[0],
|
||||
})
|
||||
layoutContext.pin.dispatch?.({
|
||||
msg: 'set_pin',
|
||||
trackReference: screenShareTracks[0],
|
||||
})
|
||||
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
|
||||
} else if (
|
||||
lastAutoFocusedScreenShareTrack.current &&
|
||||
!screenShareTracks.some(
|
||||
(track) =>
|
||||
track.publication.trackSid ===
|
||||
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
|
||||
)
|
||||
) {
|
||||
log.debug('Auto clearing screen share focus.')
|
||||
layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
|
||||
lastAutoFocusedScreenShareTrack.current = null
|
||||
}
|
||||
|
||||
if (focusTrack && !isTrackReference(focusTrack)) {
|
||||
const updatedFocusTrack = tracks.find(
|
||||
(tr) =>
|
||||
@@ -303,14 +281,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
>
|
||||
<ParticipantTile />
|
||||
</CarouselLayout>
|
||||
{focusTrack && (
|
||||
<FocusArea
|
||||
focusTrack={focusTrack}
|
||||
secondaryScreenShareTracks={
|
||||
secondaryScreenShareTracks
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||
</FocusLayoutContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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