mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-08 14:32:32 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b074783ee |
@@ -75,7 +75,6 @@ db.sqlite3
|
||||
# IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
.cursor/
|
||||
*.iml
|
||||
.devcontainer
|
||||
|
||||
|
||||
+1
-2
@@ -10,12 +10,11 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) share OneToOneFocusLayout between PiP and main room
|
||||
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
|
||||
|
||||
### Changed
|
||||
|
||||
- ⚡️(backend) hash application secrets with SHA-256
|
||||
- ⚡️(backend) reduce domain queries on the application token endpoint
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.14 on 2026-10-07 18:45
|
||||
|
||||
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||
from django.db import migrations, models
|
||||
from django.db.models.functions import Upper
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('core', '0025_application_client_secret_sha256'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name="user",
|
||||
index=models.Index(
|
||||
Upper("email"),
|
||||
name="user_email_upper_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -246,6 +246,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
name="unique_email_when_sub_is_null",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.email or self.admin_email or str(self.id)
|
||||
@@ -903,12 +906,11 @@ class Application(BaseModel):
|
||||
def can_delegate_email(self, email):
|
||||
"""Check if this application can delegate the given email."""
|
||||
|
||||
allowed_domains = {d.domain for d in self.allowed_domains.all()}
|
||||
if not allowed_domains:
|
||||
if not self.allowed_domains.exists():
|
||||
return True # No domain restrictions
|
||||
|
||||
domain = get_domain_from_email(email)
|
||||
return bool(domain) and domain.lower() in allowed_domains
|
||||
return self.allowed_domains.filter(domain__iexact=domain).exists()
|
||||
|
||||
|
||||
class ApplicationDomain(BaseModel):
|
||||
|
||||
@@ -87,60 +87,6 @@ def test_api_applications_generate_token_success(settings):
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"restricted", [True, False], ids=["restricted", "unrestricted"]
|
||||
)
|
||||
def test_api_applications_generate_token_success_query_count(
|
||||
restricted, django_assert_num_queries
|
||||
):
|
||||
"""An existing user needs one query each for application, domains, and user."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
plain_secret = "test-secret-123"
|
||||
application = ApplicationFactory(client_secret=plain_secret)
|
||||
if restricted:
|
||||
ApplicationDomainFactory(application=application, domain="example.com")
|
||||
|
||||
client = APIClient()
|
||||
with django_assert_num_queries(3):
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.data
|
||||
|
||||
|
||||
def test_api_applications_generate_token_invalid_credentials_query_count(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""An invalid secret must be rejected before querying domains or users."""
|
||||
application = ApplicationFactory(client_secret="test-secret-123")
|
||||
ApplicationDomainFactory(application=application, domain="example.com")
|
||||
|
||||
client = APIClient()
|
||||
with django_assert_num_queries(1):
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": "wrong-secret",
|
||||
"grant_type": "client_credentials",
|
||||
"scope": "user@example.com",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid credentials" in str(response.data)
|
||||
|
||||
|
||||
def test_api_applications_generate_token_form_urlencoded(settings):
|
||||
"""The token endpoint should accept "application/x-www-form-urlencoded"
|
||||
requests, as mandated by RFC 6749 (sections 3.2 and 4.4.2) for OAuth 2.0
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { memo } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { cva } from '@/styled-system/css'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||
import { getTrackKey } from '@/features/layout/utils/trackSelection'
|
||||
import { GridLayout } from '@/features/layout/components/GridLayout'
|
||||
|
||||
type OneToOneFocusLayoutProps = {
|
||||
mainTrack?: TrackReferenceOrPlaceholder
|
||||
mainTracks?: TrackReferenceOrPlaceholder[]
|
||||
thumbnailTrack?: TrackReferenceOrPlaceholder
|
||||
thumbnailMinimized?: boolean
|
||||
disableTileControls?: boolean
|
||||
/** Controls thumbnail dimensions – 'pip' for small PiP window, 'room' for the main viewport. */
|
||||
context?: 'pip' | 'room'
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus layout for 1-to-1 calls: one main tile filling the area (letterboxed)
|
||||
* with an optional thumbnail overlay at the bottom-right. When the user keeps
|
||||
* their tile in the corner with more participants, remotes share the main area
|
||||
* as a grid.
|
||||
*
|
||||
* Shared between PiP and the main room – pass `disableTileControls` in PiP
|
||||
* where hover controls should be hidden.
|
||||
*/
|
||||
export const OneToOneFocusLayout = memo(
|
||||
({
|
||||
mainTrack,
|
||||
mainTracks,
|
||||
thumbnailTrack,
|
||||
thumbnailMinimized = false,
|
||||
disableTileControls,
|
||||
context = 'room',
|
||||
}: OneToOneFocusLayoutProps) => {
|
||||
const resolvedMainTracks = mainTracks ?? (mainTrack ? [mainTrack] : [])
|
||||
|
||||
return (
|
||||
<FocusContainer>
|
||||
{resolvedMainTracks.length === 1 && (
|
||||
<MainSlot letterbox>
|
||||
<ParticipantTile
|
||||
key={getTrackKey(resolvedMainTracks[0])}
|
||||
trackRef={resolvedMainTracks[0]}
|
||||
disableTileControls={disableTileControls}
|
||||
/>
|
||||
</MainSlot>
|
||||
)}
|
||||
{resolvedMainTracks.length > 1 && (
|
||||
<MainSlot>
|
||||
<GridLayout
|
||||
tracks={resolvedMainTracks}
|
||||
style={{ height: '100%', padding: 0 }}
|
||||
>
|
||||
<ParticipantTile disableTileControls={disableTileControls} />
|
||||
</GridLayout>
|
||||
</MainSlot>
|
||||
)}
|
||||
{thumbnailTrack && (
|
||||
<Thumbnail context={context} minimized={thumbnailMinimized}>
|
||||
<ParticipantTile
|
||||
key={getTrackKey(thumbnailTrack)}
|
||||
trackRef={thumbnailTrack}
|
||||
disableTileControls={disableTileControls}
|
||||
/>
|
||||
</Thumbnail>
|
||||
)}
|
||||
</FocusContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
OneToOneFocusLayout.displayName = 'OneToOneFocusLayout'
|
||||
|
||||
const FocusContainer = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
})
|
||||
|
||||
const MainSlot = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
'& .lk-participant-tile': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
letterbox: {
|
||||
true: {
|
||||
'& .lk-participant-media-video': {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const Thumbnail = styled(
|
||||
'div',
|
||||
cva({
|
||||
base: {
|
||||
position: 'absolute',
|
||||
right: '1.25rem',
|
||||
bottom: '1.25rem',
|
||||
aspectRatio: '16 / 9',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'md',
|
||||
zIndex: 2,
|
||||
'& .lk-participant-tile': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
context: {
|
||||
pip: {
|
||||
width: '42%',
|
||||
maxWidth: '220px',
|
||||
minWidth: '140px',
|
||||
},
|
||||
room: {
|
||||
width: '20%',
|
||||
maxWidth: '320px',
|
||||
minWidth: '180px',
|
||||
},
|
||||
},
|
||||
minimized: {
|
||||
true: {
|
||||
width: '14%',
|
||||
maxWidth: '180px',
|
||||
minWidth: '128px',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
context: 'room',
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -12,12 +12,7 @@ import {
|
||||
import { Track } from 'livekit-client'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { OneToOneFocusLayout } from '@/features/layout/components/OneToOneFocusLayout'
|
||||
import {
|
||||
shouldUseFocusLayout,
|
||||
splitFocusTracks,
|
||||
} from '@/features/layout/utils/selfTileLayout'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export const StageLayout = () => {
|
||||
const lastAutoFocusedScreenShareTrack =
|
||||
@@ -35,28 +30,12 @@ export const StageLayout = () => {
|
||||
.filter(isTrackReference)
|
||||
.filter((track) => track.publication.source === Track.Source.ScreenShare)
|
||||
|
||||
const { pinnedTrackRef, selfTileLayout, selfTileMinimized } =
|
||||
useSnapshot(layoutStore)
|
||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||
|
||||
const carouselTracks = tracks.filter(
|
||||
(track) => !isEqualTrackRef(track, pinnedTrackRef)
|
||||
)
|
||||
|
||||
const cameraTracks = useMemo(
|
||||
() => tracks.filter((t) => t.source === Track.Source.Camera),
|
||||
[tracks]
|
||||
)
|
||||
|
||||
const useFocusLayout =
|
||||
!pinnedTrackRef &&
|
||||
screenShareTracks.length === 0 &&
|
||||
shouldUseFocusLayout(selfTileLayout, cameraTracks)
|
||||
|
||||
const { mainTracks, thumbnailTrack } = useMemo(
|
||||
() => splitFocusTracks(cameraTracks),
|
||||
[cameraTracks]
|
||||
)
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
||||
useEffect(() => {
|
||||
@@ -108,13 +87,7 @@ export const StageLayout = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{useFocusLayout ? (
|
||||
<OneToOneFocusLayout
|
||||
mainTracks={mainTracks}
|
||||
thumbnailTrack={thumbnailTrack}
|
||||
thumbnailMinimized={selfTileLayout === 'corner' && selfTileMinimized}
|
||||
/>
|
||||
) : !pinnedTrackRef ? (
|
||||
{!pinnedTrackRef ? (
|
||||
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
|
||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||
<ParticipantTile />
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import type { SelfTileLayout } from '@/stores/layout'
|
||||
|
||||
const remoteTracks = (cameraTracks: TrackReferenceOrPlaceholder[]) =>
|
||||
cameraTracks.filter((track) => !track.participant?.isLocal)
|
||||
|
||||
/**
|
||||
* Focus layout (main area + optional local thumbnail) instead of the original grid.
|
||||
* Pin and screen share are handled by the caller: they take the stage over this.
|
||||
*/
|
||||
export const shouldUseFocusLayout = (
|
||||
layout: SelfTileLayout,
|
||||
cameraTracks: TrackReferenceOrPlaceholder[]
|
||||
): boolean => {
|
||||
if (layout === 'classic') return false
|
||||
if (layout === 'corner') return true
|
||||
return cameraTracks.length <= 2
|
||||
}
|
||||
|
||||
export const hasRemoteCamera = (cameraTracks: TrackReferenceOrPlaceholder[]) =>
|
||||
remoteTracks(cameraTracks).length > 0
|
||||
|
||||
export const splitFocusTracks = (
|
||||
cameraTracks: TrackReferenceOrPlaceholder[]
|
||||
) => {
|
||||
const local = cameraTracks.find((track) => track.participant?.isLocal)
|
||||
const remotes = remoteTracks(cameraTracks)
|
||||
return {
|
||||
mainTracks: remotes.length > 0 ? remotes : local ? [local] : [],
|
||||
thumbnailTrack: remotes.length > 0 ? local : undefined,
|
||||
}
|
||||
}
|
||||
+3
-17
@@ -7,9 +7,6 @@ import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { FocusButton } from './FocusButton'
|
||||
import { EffectsButton } from './EffectsButton'
|
||||
import { MuteButton } from './MuteButton'
|
||||
import { SelfTileMenu } from './SelfTileMenu'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
const MOUSE_IDLE_TIME = 3000
|
||||
|
||||
@@ -17,7 +14,6 @@ type FadeOverlayProps = {
|
||||
children: ReactNode
|
||||
hasKeyboardFocus: boolean
|
||||
tileRef: React.RefObject<HTMLDivElement | null>
|
||||
forceVisible?: boolean
|
||||
}
|
||||
|
||||
// Pointer-events none so this overlay doesn't block the zoom surface below.
|
||||
@@ -28,7 +24,6 @@ const FadeOverlay = ({
|
||||
children,
|
||||
hasKeyboardFocus,
|
||||
tileRef,
|
||||
forceVisible = false,
|
||||
}: FadeOverlayProps) => {
|
||||
const [active, setActive] = useState(false)
|
||||
const idleTimerRef = useRef<number | null>(null)
|
||||
@@ -68,7 +63,8 @@ const FadeOverlay = ({
|
||||
}
|
||||
}, [tileRef])
|
||||
|
||||
const isVisible = hasKeyboardFocus || active || forceVisible
|
||||
const isVisible = hasKeyboardFocus || active
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -103,18 +99,9 @@ export const ParticipantTileFocus = ({
|
||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||
const isLocal = participant.isLocal
|
||||
const canMute = useCanMute(participant)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||
// A pin (screen shares auto-pin) replaces the corner layout with the carousel,
|
||||
// where the self-tile actions have no effect.
|
||||
const showSelfTileMenu = isLocal && !isScreenShare && !pinnedTrackRef
|
||||
|
||||
return (
|
||||
<FadeOverlay
|
||||
hasKeyboardFocus={hasKeyboardFocus}
|
||||
tileRef={tileRef}
|
||||
forceVisible={menuOpen}
|
||||
>
|
||||
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus} tileRef={tileRef}>
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50',
|
||||
@@ -140,7 +127,6 @@ export const ParticipantTileFocus = ({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showSelfTileMenu && <SelfTileMenu onOpenChange={setMenuOpen} />}
|
||||
</HStack>
|
||||
</div>
|
||||
</FadeOverlay>
|
||||
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
import { Menu as RACMenu, MenuItem } from 'react-aria-components'
|
||||
import {
|
||||
RiContractLeftLine,
|
||||
RiExpandRightLine,
|
||||
RiMoreFill,
|
||||
RiPictureInPictureExitLine,
|
||||
RiPictureInPictureLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTracks } from '@livekit/components-react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import {
|
||||
hideSelfTile,
|
||||
layoutStore,
|
||||
showSelfTile,
|
||||
toggleSelfTileMinimized,
|
||||
} from '@/stores/layout'
|
||||
import {
|
||||
hasRemoteCamera,
|
||||
shouldUseFocusLayout,
|
||||
} from '@/features/layout/utils/selfTileLayout'
|
||||
|
||||
type SelfTileMenuProps = {
|
||||
onOpenChange?: (isOpen: boolean) => void
|
||||
}
|
||||
|
||||
export const SelfTileMenu = ({ onOpenChange }: SelfTileMenuProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const announce = useScreenReaderAnnounce()
|
||||
const { selfTileLayout, selfTileMinimized } = useSnapshot(layoutStore)
|
||||
const cameraTracks = useTracks(
|
||||
[{ source: Track.Source.Camera, withPlaceholder: true }],
|
||||
{ updateOnlyOn: [], onlySubscribed: false }
|
||||
)
|
||||
|
||||
if (!hasRemoteCamera(cameraTracks)) return null
|
||||
|
||||
const isInCorner = shouldUseFocusLayout(selfTileLayout, cameraTracks)
|
||||
const isMinimized = selfTileLayout === 'corner' && selfTileMinimized
|
||||
const itemClassName = menuRecipe({ icon: true, variant: 'dark' }).item
|
||||
|
||||
return (
|
||||
<Menu variant="dark" placement="top" onOpenChange={onOpenChange}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
aria-label={t('moreOptions')}
|
||||
tooltip={t('moreOptions')}
|
||||
>
|
||||
<RiMoreFill />
|
||||
</Button>
|
||||
<RACMenu style={{ minWidth: '220px' }}>
|
||||
{isInCorner && (
|
||||
<MenuItem
|
||||
className={itemClassName}
|
||||
onAction={() => {
|
||||
toggleSelfTileMinimized()
|
||||
announce(
|
||||
isMinimized
|
||||
? t('announcements.expanded')
|
||||
: t('announcements.reduced')
|
||||
)
|
||||
}}
|
||||
>
|
||||
{isMinimized ? (
|
||||
<RiExpandRightLine size={20} />
|
||||
) : (
|
||||
<RiContractLeftLine size={20} />
|
||||
)}
|
||||
{isMinimized ? t('expand') : t('reduce')}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
className={itemClassName}
|
||||
onAction={() => {
|
||||
if (isInCorner) {
|
||||
hideSelfTile()
|
||||
announce(t('announcements.hidden'))
|
||||
return
|
||||
}
|
||||
showSelfTile()
|
||||
announce(t('announcements.shown'))
|
||||
}}
|
||||
>
|
||||
{isInCorner ? (
|
||||
<RiPictureInPictureExitLine size={20} />
|
||||
) : (
|
||||
<RiPictureInPictureLine size={20} />
|
||||
)}
|
||||
{isInCorner ? t('hideTile') : t('showTile')}
|
||||
</MenuItem>
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { memo } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||
|
||||
type PipFocusLayoutProps = {
|
||||
mainTrack?: TrackReferenceOrPlaceholder
|
||||
thumbnailTrack?: TrackReferenceOrPlaceholder
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus layout used when 1-2 tracks are visible in the PiP window.
|
||||
*
|
||||
* The main tile is letterboxed (object-fit: contain) so the camera is
|
||||
* never stretched to a non-video aspect and leaves dark padding
|
||||
* above/below when the window shape doesn't match the source.
|
||||
* The thumbnail keeps the usual cover fill.
|
||||
*/
|
||||
export const PipFocusLayout = memo(
|
||||
({ mainTrack, thumbnailTrack }: PipFocusLayoutProps) => {
|
||||
return (
|
||||
<FocusContainer>
|
||||
{mainTrack && (
|
||||
<MainSlot>
|
||||
<ParticipantTile
|
||||
key={getTrackKey(mainTrack)}
|
||||
trackRef={mainTrack}
|
||||
disableTileControls
|
||||
/>
|
||||
</MainSlot>
|
||||
)}
|
||||
{thumbnailTrack && (
|
||||
<Thumbnail>
|
||||
<ParticipantTile
|
||||
key={getTrackKey(thumbnailTrack)}
|
||||
trackRef={thumbnailTrack}
|
||||
disableTileControls
|
||||
/>
|
||||
</Thumbnail>
|
||||
)}
|
||||
</FocusContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
PipFocusLayout.displayName = 'PipFocusLayout'
|
||||
|
||||
const FocusContainer = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
})
|
||||
|
||||
const MainSlot = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
'& .lk-participant-media-video': {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const Thumbnail = styled('div', {
|
||||
base: {
|
||||
position: 'absolute',
|
||||
right: '1.25rem',
|
||||
bottom: '1.25rem',
|
||||
width: '42%',
|
||||
maxWidth: '220px',
|
||||
minWidth: '140px',
|
||||
aspectRatio: '16 / 9',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'md',
|
||||
zIndex: 2,
|
||||
},
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { styled } from '@/styled-system/jsx'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
||||
import { computePipGridLayout } from '../../utils/pipGrid'
|
||||
import { getTrackKey } from '@/features/layout/utils/trackSelection'
|
||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||
|
||||
type PipGridLayoutProps = {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
|
||||
@@ -2,7 +2,7 @@ import { memo } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||
import { getTrackKey } from '@/features/layout/utils/trackSelection'
|
||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||
|
||||
type PipScreenShareLayoutProps = {
|
||||
screenShareTrack: TrackReferenceOrPlaceholder
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { OneToOneFocusLayout } from '@/features/layout/components/OneToOneFocusLayout'
|
||||
import { PipFocusLayout } from './PipFocusLayout'
|
||||
import { PipGridLayout } from './PipGridLayout'
|
||||
import { PaginationControl } from '@/features/layout/components/PaginationControl'
|
||||
import { PipScreenShareLayout } from './PipScreenShareLayout'
|
||||
@@ -59,11 +59,9 @@ export const PipStage = () => {
|
||||
if (cameraTracks.length <= 1) {
|
||||
return (
|
||||
<StageFrame>
|
||||
<OneToOneFocusLayout
|
||||
<PipFocusLayout
|
||||
mainTrack={screenShareTrack}
|
||||
thumbnailTrack={cameraTracks[0]}
|
||||
disableTileControls
|
||||
context="pip"
|
||||
/>
|
||||
</StageFrame>
|
||||
)
|
||||
@@ -101,12 +99,7 @@ export const PipStage = () => {
|
||||
|
||||
return (
|
||||
<StageFrame>
|
||||
<OneToOneFocusLayout
|
||||
mainTrack={mainTrack}
|
||||
thumbnailTrack={thumbnailTrack}
|
||||
disableTileControls
|
||||
context="pip"
|
||||
/>
|
||||
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
|
||||
</StageFrame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -754,18 +754,7 @@
|
||||
},
|
||||
"effects": "Hintergründe und Effekte anwenden",
|
||||
"muteParticipant": "{{name}} stummschalten",
|
||||
"fullScreen": "Vollbild",
|
||||
"moreOptions": "Weitere Optionen",
|
||||
"reduce": "Verkleinern",
|
||||
"expand": "Vergrößern",
|
||||
"hideTile": "Diese Kachel entfernen",
|
||||
"showTile": "Kachel anzeigen",
|
||||
"announcements": {
|
||||
"reduced": "Deine Kachel ist verkleinert.",
|
||||
"expanded": "Deine Kachel ist vergrößert.",
|
||||
"shown": "Deine Kachel wird unten rechts angezeigt.",
|
||||
"hidden": "Das ursprüngliche Layout ist wiederhergestellt."
|
||||
}
|
||||
"fullScreen": "Vollbild"
|
||||
},
|
||||
"screenShareZoom": {
|
||||
"toolbarLabel": "Zoom-Steuerung für Bildschirmfreigabe",
|
||||
|
||||
@@ -754,18 +754,7 @@
|
||||
},
|
||||
"effects": "Apply backgrounds and effects",
|
||||
"muteParticipant": "Mute {{name}}",
|
||||
"fullScreen": "Full screen",
|
||||
"moreOptions": "More options",
|
||||
"reduce": "Minimize",
|
||||
"expand": "Enlarge",
|
||||
"hideTile": "Remove this tile",
|
||||
"showTile": "Show tile",
|
||||
"announcements": {
|
||||
"reduced": "Your tile is minimized.",
|
||||
"expanded": "Your tile is enlarged.",
|
||||
"shown": "Your tile is shown at the bottom right.",
|
||||
"hidden": "The original layout is restored."
|
||||
}
|
||||
"fullScreen": "Full screen"
|
||||
},
|
||||
"screenShareZoom": {
|
||||
"toolbarLabel": "Screen share zoom controls",
|
||||
|
||||
@@ -754,18 +754,7 @@
|
||||
},
|
||||
"effects": "Arrière-plans et effets",
|
||||
"muteParticipant": "Couper le micro de {{name}}",
|
||||
"fullScreen": "Plein écran",
|
||||
"moreOptions": "Plus d'options",
|
||||
"reduce": "Réduire",
|
||||
"expand": "Agrandir",
|
||||
"hideTile": "Supprimer cette tuile",
|
||||
"showTile": "Afficher la tuile",
|
||||
"announcements": {
|
||||
"reduced": "Votre tuile est réduite.",
|
||||
"expanded": "Votre tuile est agrandie.",
|
||||
"shown": "Votre tuile est affichée en bas à droite.",
|
||||
"hidden": "La disposition d’origine est rétablie."
|
||||
}
|
||||
"fullScreen": "Plein écran"
|
||||
},
|
||||
"screenShareZoom": {
|
||||
"toolbarLabel": "Contrôles de zoom du partage d'écran",
|
||||
|
||||
@@ -754,18 +754,7 @@
|
||||
},
|
||||
"effects": "Pas visuele effecten toe",
|
||||
"muteParticipant": "Demp {{name}}",
|
||||
"fullScreen": "Volledig scherm",
|
||||
"moreOptions": "Meer opties",
|
||||
"reduce": "Verkleinen",
|
||||
"expand": "Vergroten",
|
||||
"hideTile": "Deze tegel verwijderen",
|
||||
"showTile": "Tegel weergeven",
|
||||
"announcements": {
|
||||
"reduced": "Je tegel is verkleind.",
|
||||
"expanded": "Je tegel is vergroot.",
|
||||
"shown": "Je tegel wordt rechtsonder weergegeven.",
|
||||
"hidden": "De oorspronkelijke indeling is hersteld."
|
||||
}
|
||||
"fullScreen": "Volledig scherm"
|
||||
},
|
||||
"screenShareZoom": {
|
||||
"toolbarLabel": "Zoombediening voor schermdeling",
|
||||
|
||||
@@ -10,16 +10,14 @@ export const Menu = ({
|
||||
children,
|
||||
variant = 'light',
|
||||
placement,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: [trigger: ReactNode, menu: ReactNode]
|
||||
variant?: 'dark' | 'light'
|
||||
placement?: 'bottom' | 'top' | 'left' | 'right'
|
||||
onOpenChange?: (isOpen: boolean) => void
|
||||
}) => {
|
||||
const [trigger, menu] = children
|
||||
return (
|
||||
<MenuTrigger onOpenChange={onOpenChange}>
|
||||
<MenuTrigger>
|
||||
{trigger}
|
||||
<StyledPopover placement={placement}>
|
||||
<Box size="sm" type="popover" variant={variant}>
|
||||
|
||||
@@ -7,14 +7,6 @@ import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
|
||||
type PinnedTrackRef = ReturnType<typeof ref<TrackReferenceOrPlaceholder>>
|
||||
|
||||
/**
|
||||
* Where the local camera sits in the stage.
|
||||
* - auto: corner thumbnail in 1-to-1, original grid otherwise
|
||||
* - corner: keep the local tile bottom-right even with more participants
|
||||
* - classic: original grid, including in 1-to-1
|
||||
*/
|
||||
export type SelfTileLayout = 'auto' | 'corner' | 'classic'
|
||||
|
||||
type State = {
|
||||
showHeader: boolean
|
||||
showFooter: boolean
|
||||
@@ -23,8 +15,6 @@ type State = {
|
||||
activeSubPanelId: SubPanelId | null
|
||||
showReactionsToolbar: boolean
|
||||
pinnedTrackRef?: PinnedTrackRef
|
||||
selfTileLayout: SelfTileLayout
|
||||
selfTileMinimized: boolean
|
||||
}
|
||||
|
||||
export const layoutStore = proxy<State>({
|
||||
@@ -35,8 +25,6 @@ export const layoutStore = proxy<State>({
|
||||
activeSubPanelId: null,
|
||||
showReactionsToolbar: false,
|
||||
pinnedTrackRef: undefined,
|
||||
selfTileLayout: 'auto',
|
||||
selfTileMinimized: false,
|
||||
})
|
||||
|
||||
export const setPinnedTrack = (trackRef: TrackReferenceOrPlaceholder): void => {
|
||||
@@ -47,26 +35,6 @@ export const clearPinnedTrack = (): void => {
|
||||
layoutStore.pinnedTrackRef = undefined
|
||||
}
|
||||
|
||||
/** Also opts into the corner layout, so the size choice survives past 2 participants. */
|
||||
export const toggleSelfTileMinimized = (): void => {
|
||||
const isMinimized =
|
||||
layoutStore.selfTileLayout === 'corner' && layoutStore.selfTileMinimized
|
||||
layoutStore.selfTileLayout = 'corner'
|
||||
layoutStore.selfTileMinimized = !isMinimized
|
||||
}
|
||||
|
||||
/** Show the local tile as a bottom-right thumbnail and keep it past 2 participants. */
|
||||
export const showSelfTile = (): void => {
|
||||
layoutStore.selfTileLayout = 'corner'
|
||||
layoutStore.selfTileMinimized = false
|
||||
}
|
||||
|
||||
/** Leave the corner thumbnail and return to the original grid. */
|
||||
export const hideSelfTile = (): void => {
|
||||
layoutStore.selfTileLayout = 'classic'
|
||||
layoutStore.selfTileMinimized = false
|
||||
}
|
||||
|
||||
export const closeSidePanel = (): void => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
|
||||
Reference in New Issue
Block a user