Compare commits

..

6 Commits

Author SHA1 Message Date
Ovgodd e897a1635a ✨(frontend) let the local tile leave or keep the corner layout
1:1 local tile stays corner or returns to grid via menu; changes are announced
2026-10-08 16:17:33 +02:00
Cyril 7ff2a0c52c ✨(frontend) apply OneToOneFocusLayout to main room 1-to-1
Show remote fullscreen with local thumbnail when no pin.
2026-10-08 16:17:11 +02:00
Cyril 10bb2b1e10 ♻️(frontend) use OneToOneFocusLayout in PiP stage
Replace PipFocusLayout with the shared focus layout.
2026-10-08 16:16:27 +02:00
Cyril 66b7007b27 ✨(frontend) add shared OneToOneFocusLayout component
Reuse the PiP main + thumbnail pattern for room and PiP.
2026-10-08 16:16:27 +02:00
Cyril a5860d6a8e ♻️(frontend) extract getTrackKey to shared layout utils
Move track key helper out of pip so layouts can reuse it.
2026-10-08 16:16:27 +02:00
lebaudantoine deb1ef9ed6 ⚡️(backend) reduce domain queries on the application token endpoint
Load allowed domains once and compare the lowercased email domain
in Python. For restricted applications with an existing user,
this reduces successful token requests from four queries to three.

Keep domain loading after credential validation so rejected
credentials require only the application lookup.

Add endpoint query-count tests for restricted and unrestricted
successes and invalid credentials.
2026-10-07 19:06:05 +02:00
21 changed files with 486 additions and 131 deletions
+1
View File
@@ -75,6 +75,7 @@ db.sqlite3
# IDEs
.idea/
.vscode/
.cursor/
*.iml
.devcontainer
+2 -1
View File
@@ -10,11 +10,12 @@ and this project adheres to
### Added
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
- ✨(frontend) share OneToOneFocusLayout between PiP and main room
### Changed
- ⚡️(backend) hash application secrets with SHA-256
- ⚡️(backend) reduce domain queries on the application token endpoint
### Fixed
@@ -1,23 +0,0 @@
# 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",
),
),
]
+3 -5
View File
@@ -246,9 +246,6 @@ 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)
@@ -906,11 +903,12 @@ class Application(BaseModel):
def can_delegate_email(self, email):
"""Check if this application can delegate the given email."""
if not self.allowed_domains.exists():
allowed_domains = {d.domain for d in self.allowed_domains.all()}
if not allowed_domains:
return True # No domain restrictions
domain = get_domain_from_email(email)
return self.allowed_domains.filter(domain__iexact=domain).exists()
return bool(domain) and domain.lower() in allowed_domains
class ApplicationDomain(BaseModel):
@@ -87,6 +87,60 @@ 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
@@ -0,0 +1,152 @@
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,7 +12,12 @@ import {
import { Track } from 'livekit-client'
import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import { useEffect, useRef } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { OneToOneFocusLayout } from '@/features/layout/components/OneToOneFocusLayout'
import {
shouldUseFocusLayout,
splitFocusTracks,
} from '@/features/layout/utils/selfTileLayout'
export const StageLayout = () => {
const lastAutoFocusedScreenShareTrack =
@@ -30,12 +35,28 @@ export const StageLayout = () => {
.filter(isTrackReference)
.filter((track) => track.publication.source === Track.Source.ScreenShare)
const { pinnedTrackRef } = useSnapshot(layoutStore)
const { pinnedTrackRef, selfTileLayout, selfTileMinimized } =
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(() => {
@@ -87,7 +108,13 @@ export const StageLayout = () => {
return (
<>
{!pinnedTrackRef ? (
{useFocusLayout ? (
<OneToOneFocusLayout
mainTracks={mainTracks}
thumbnailTrack={thumbnailTrack}
thumbnailMinimized={selfTileLayout === 'corner' && selfTileMinimized}
/>
) : !pinnedTrackRef ? (
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
@@ -0,0 +1,32 @@
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,
}
}
@@ -7,6 +7,9 @@ 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
@@ -14,6 +17,7 @@ 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.
@@ -24,6 +28,7 @@ const FadeOverlay = ({
children,
hasKeyboardFocus,
tileRef,
forceVisible = false,
}: FadeOverlayProps) => {
const [active, setActive] = useState(false)
const idleTimerRef = useRef<number | null>(null)
@@ -63,8 +68,7 @@ const FadeOverlay = ({
}
}, [tileRef])
const isVisible = hasKeyboardFocus || active
const isVisible = hasKeyboardFocus || active || forceVisible
return (
<div
className={css({
@@ -99,9 +103,18 @@ 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}>
<FadeOverlay
hasKeyboardFocus={hasKeyboardFocus}
tileRef={tileRef}
forceVisible={menuOpen}
>
<div
className={css({
backgroundColor: 'primaryDark.50',
@@ -127,6 +140,7 @@ export const ParticipantTileFocus = ({
)}
</>
)}
{showSelfTileMenu && <SelfTileMenu onOpenChange={setMenuOpen} />}
</HStack>
</div>
</FadeOverlay>
@@ -0,0 +1,100 @@
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>
)
}
@@ -1,86 +0,0 @@
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 '../../utils/pipTrackSelection'
import { getTrackKey } from '@/features/layout/utils/trackSelection'
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 '../../utils/pipTrackSelection'
import { getTrackKey } from '@/features/layout/utils/trackSelection'
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 { PipFocusLayout } from './PipFocusLayout'
import { OneToOneFocusLayout } from '@/features/layout/components/OneToOneFocusLayout'
import { PipGridLayout } from './PipGridLayout'
import { PaginationControl } from '@/features/layout/components/PaginationControl'
import { PipScreenShareLayout } from './PipScreenShareLayout'
@@ -59,9 +59,11 @@ export const PipStage = () => {
if (cameraTracks.length <= 1) {
return (
<StageFrame>
<PipFocusLayout
<OneToOneFocusLayout
mainTrack={screenShareTrack}
thumbnailTrack={cameraTracks[0]}
disableTileControls
context="pip"
/>
</StageFrame>
)
@@ -99,7 +101,12 @@ export const PipStage = () => {
return (
<StageFrame>
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
<OneToOneFocusLayout
mainTrack={mainTrack}
thumbnailTrack={thumbnailTrack}
disableTileControls
context="pip"
/>
</StageFrame>
)
}
+12 -1
View File
@@ -754,7 +754,18 @@
},
"effects": "Hintergründe und Effekte anwenden",
"muteParticipant": "{{name}} stummschalten",
"fullScreen": "Vollbild"
"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."
}
},
"screenShareZoom": {
"toolbarLabel": "Zoom-Steuerung für Bildschirmfreigabe",
+12 -1
View File
@@ -754,7 +754,18 @@
},
"effects": "Apply backgrounds and effects",
"muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen"
"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."
}
},
"screenShareZoom": {
"toolbarLabel": "Screen share zoom controls",
+12 -1
View File
@@ -754,7 +754,18 @@
},
"effects": "Arrière-plans et effets",
"muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran"
"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."
}
},
"screenShareZoom": {
"toolbarLabel": "Contrôles de zoom du partage d'écran",
+12 -1
View File
@@ -754,7 +754,18 @@
},
"effects": "Pas visuele effecten toe",
"muteParticipant": "Demp {{name}}",
"fullScreen": "Volledig scherm"
"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."
}
},
"screenShareZoom": {
"toolbarLabel": "Zoombediening voor schermdeling",
+3 -1
View File
@@ -10,14 +10,16 @@ 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>
<MenuTrigger onOpenChange={onOpenChange}>
{trigger}
<StyledPopover placement={placement}>
<Box size="sm" type="popover" variant={variant}>
+32
View File
@@ -7,6 +7,14 @@ 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
@@ -15,6 +23,8 @@ type State = {
activeSubPanelId: SubPanelId | null
showReactionsToolbar: boolean
pinnedTrackRef?: PinnedTrackRef
selfTileLayout: SelfTileLayout
selfTileMinimized: boolean
}
export const layoutStore = proxy<State>({
@@ -25,6 +35,8 @@ export const layoutStore = proxy<State>({
activeSubPanelId: null,
showReactionsToolbar: false,
pinnedTrackRef: undefined,
selfTileLayout: 'auto',
selfTileMinimized: false,
})
export const setPinnedTrack = (trackRef: TrackReferenceOrPlaceholder): void => {
@@ -35,6 +47,26 @@ 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