(frontend) show room notifications inside the PiP window

Render the shared toast queue in PiP.
This commit is contained in:
Cyril
2026-04-27 11:04:11 +02:00
parent c8ae87b082
commit d7fb31ef2c
13 changed files with 540 additions and 256 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client' import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ChatMessage, isMobileBrowser } from '@livekit/components-core' import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
@@ -24,12 +24,18 @@ export const MainNotificationToast = () => {
const { appendReaction } = useReactions() const { appendReaction } = useReactions()
// Chat uses keepAlive in multiple SidePanels (main + PiP), so the same
// message event can fire more than once. Track the last id to deduplicate.
const lastChatMsgIdRef = useRef<string>('')
useEffect(() => { useEffect(() => {
const handleChatMessage = ( const handleChatMessage = (
chatMessage: ChatMessage, chatMessage: ChatMessage,
participant?: Participant | undefined participant?: Participant | undefined
) => { ) => {
if (!participant || participant.isLocal) return if (!participant || participant.isLocal) return
if (chatMessage.id && chatMessage.id === lastChatMsgIdRef.current) return
lastChatMsgIdRef.current = chatMessage.id ?? ''
triggerNotificationSound(NotificationType.MessageReceived) triggerNotificationSound(NotificationType.MessageReceived)
toastQueue.add( toastQueue.add(
{ {
@@ -170,7 +170,7 @@ export const DocumentPiPPortal = ({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [closePiP, isOpen, isSupported, openPiP]) }, [closePiP, isOpen, isSupported, openPiP, t])
// Focus stays on the trigger; PiP is announced as an auxiliary surface. // Focus stays on the trigger; PiP is announced as an auxiliary surface.
useEffect(() => { useEffect(() => {
@@ -11,6 +11,8 @@ import { usePipRestoreFocus } from '../hooks/usePipRestoreFocus'
import { PipControlBar } from './PipControlBar' import { PipControlBar } from './PipControlBar'
import { PipReactionsToolbar } from './PipReactionsToolbar' import { PipReactionsToolbar } from './PipReactionsToolbar'
import { PipStage } from './layouts/PipStage' import { PipStage } from './layouts/PipStage'
import { PipNotificationOverlay } from './notifications/PipNotificationOverlay'
import { PipConnectionStateToast } from './notifications/PipConnectionStateToast'
export const PipView = () => { export const PipView = () => {
const browserSupportsScreenSharing = supportsScreenSharing() const browserSupportsScreenSharing = supportsScreenSharing()
@@ -47,6 +49,8 @@ export const PipView = () => {
<PipReactionsToolbar /> <PipReactionsToolbar />
<PipControlBar showScreenShare={browserSupportsScreenSharing} /> <PipControlBar showScreenShare={browserSupportsScreenSharing} />
<SidePanel store={pipLayoutStore} /> <SidePanel store={pipLayoutStore} />
<PipConnectionStateToast />
<PipNotificationOverlay />
</PipContainer> </PipContainer>
) )
} }
@@ -1,81 +1,81 @@
import { memo } from 'react' import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core' import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile' import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { getTrackKey } from '../../utils/pipTrackSelection' import { getTrackKey } from '../../utils/pipTrackSelection'
type PipFocusLayoutProps = { type PipFocusLayoutProps = {
mainTrack: TrackReferenceOrPlaceholder mainTrack: TrackReferenceOrPlaceholder
thumbnailTrack?: TrackReferenceOrPlaceholder thumbnailTrack?: TrackReferenceOrPlaceholder
} }
/** /**
* Focus layout used when 1-2 tracks are visible in the PiP window. * 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 * The main tile is letterboxed (object-fit: contain) so the camera is
* never stretched to a non-video aspect and leaves dark padding * never stretched to a non-video aspect and leaves dark padding
* above/below when the window shape doesn't match the source. * above/below when the window shape doesn't match the source.
* The thumbnail keeps the usual cover fill. * The thumbnail keeps the usual cover fill.
*/ */
export const PipFocusLayout = memo(function PipFocusLayout({ export const PipFocusLayout = memo(function PipFocusLayout({
mainTrack, mainTrack,
thumbnailTrack, thumbnailTrack,
}: PipFocusLayoutProps) { }: PipFocusLayoutProps) {
return ( return (
<FocusContainer> <FocusContainer>
<MainSlot> <MainSlot>
<ParticipantTile <ParticipantTile
key={getTrackKey(mainTrack)} key={getTrackKey(mainTrack)}
trackRef={mainTrack} trackRef={mainTrack}
disableMetadata disableMetadata
/> />
</MainSlot> </MainSlot>
{thumbnailTrack && ( {thumbnailTrack && (
<Thumbnail> <Thumbnail>
<ParticipantTile <ParticipantTile
key={getTrackKey(thumbnailTrack)} key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack} trackRef={thumbnailTrack}
disableMetadata disableMetadata
/> />
</Thumbnail> </Thumbnail>
)} )}
</FocusContainer> </FocusContainer>
) )
}) })
const FocusContainer = styled('div', { const FocusContainer = styled('div', {
base: { base: {
position: 'relative', position: 'relative',
width: '100%', width: '100%',
height: '100%', height: '100%',
borderRadius: '4px', borderRadius: '4px',
overflow: 'hidden', overflow: 'hidden',
backgroundColor: 'primaryDark.100', backgroundColor: 'primaryDark.100',
}, },
}) })
const MainSlot = styled('div', { const MainSlot = styled('div', {
base: { base: {
width: '100%', width: '100%',
height: '100%', height: '100%',
'& .lk-participant-media-video': { '& .lk-participant-media-video': {
objectFit: 'contain', objectFit: 'contain',
}, },
}, },
}) })
const Thumbnail = styled('div', { const Thumbnail = styled('div', {
base: { base: {
position: 'absolute', position: 'absolute',
right: '1rem', right: '1rem',
bottom: '1rem', bottom: '1rem',
width: '42%', width: '42%',
maxWidth: '220px', maxWidth: '220px',
minWidth: '140px', minWidth: '140px',
aspectRatio: '16 / 9', aspectRatio: '16 / 9',
borderRadius: '4px', borderRadius: '4px',
overflow: 'hidden', overflow: 'hidden',
boxShadow: 'md', boxShadow: 'md',
zIndex: 2, zIndex: 2,
}, },
}) })
@@ -1,82 +1,82 @@
import { memo, useMemo, useRef } from 'react' import { memo, useMemo, useRef } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core' import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile' import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { usePipElementSize } from '../../hooks/usePipElementSize' import { usePipElementSize } from '../../hooks/usePipElementSize'
import { usePipFlipAnimations } from '../../hooks/usePipFlipAnimations' import { usePipFlipAnimations } from '../../hooks/usePipFlipAnimations'
import { computePipGridLayout } from '../../utils/pipGrid' import { computePipGridLayout } from '../../utils/pipGrid'
import { getTrackKey } from '../../utils/pipTrackSelection' import { getTrackKey } from '../../utils/pipTrackSelection'
type PipGridLayoutProps = { type PipGridLayoutProps = {
tracks: TrackReferenceOrPlaceholder[] tracks: TrackReferenceOrPlaceholder[]
} }
/** /**
* Adaptive grid used when 3+ tracks are visible in the PiP window. * Adaptive grid used when 3+ tracks are visible in the PiP window.
* *
* All grid math (shape choice + partial-row stretching) is delegated to * All grid math (shape choice + partial-row stretching) is delegated to
* `computePipGridLayout`. This component only measures the container, * `computePipGridLayout`. This component only measures the container,
* applies the returned placements, and plays a FLIP animation when the * applies the returned placements, and plays a FLIP animation when the
* tile set or grid shape changes (participant joins/leaves or shape shift). * tile set or grid shape changes (participant joins/leaves or shape shift).
* *
* Tiles keep a stable key so resizing never remounts <video> elements. * Tiles keep a stable key so resizing never remounts <video> elements.
*/ */
export const PipGridLayout = memo(function PipGridLayout({ export const PipGridLayout = memo(function PipGridLayout({
tracks, tracks,
}: PipGridLayoutProps) { }: PipGridLayoutProps) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const { width, height } = usePipElementSize(containerRef) const { width, height } = usePipElementSize(containerRef)
const tileKeys = useMemo(() => tracks.map(getTrackKey), [tracks]) const tileKeys = useMemo(() => tracks.map(getTrackKey), [tracks])
const { rows, subColumns, placements } = useMemo( const { rows, subColumns, placements } = useMemo(
() => computePipGridLayout(tracks.length, width, height), () => computePipGridLayout(tracks.length, width, height),
[tracks.length, width, height] [tracks.length, width, height]
) )
const gridStyle = useMemo( const gridStyle = useMemo(
() => ({ () => ({
gridTemplateColumns: `repeat(${subColumns}, minmax(0, 1fr))`, gridTemplateColumns: `repeat(${subColumns}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
}), }),
[subColumns, rows] [subColumns, rows]
) )
usePipFlipAnimations(containerRef, tileKeys) usePipFlipAnimations(containerRef, tileKeys)
return ( return (
<GridContainer ref={containerRef} style={gridStyle}> <GridContainer ref={containerRef} style={gridStyle}>
{tracks.map((track, index) => ( {tracks.map((track, index) => (
<GridCell key={tileKeys[index]} style={placements[index]}> <GridCell key={tileKeys[index]} style={placements[index]}>
<ParticipantTile trackRef={track} disableMetadata /> <ParticipantTile trackRef={track} disableMetadata />
</GridCell> </GridCell>
))} ))}
</GridContainer> </GridContainer>
) )
}) })
const GridContainer = styled('div', { const GridContainer = styled('div', {
base: { base: {
width: '100%', width: '100%',
height: '100%', height: '100%',
display: 'grid', display: 'grid',
gap: '0.25rem', gap: '0.25rem',
}, },
}) })
const GridCell = styled('div', { const GridCell = styled('div', {
base: { base: {
position: 'relative', position: 'relative',
minWidth: 0, minWidth: 0,
minHeight: 0, minHeight: 0,
borderRadius: '4px', borderRadius: '4px',
overflow: 'hidden', overflow: 'hidden',
backgroundColor: 'primaryDark.100', backgroundColor: 'primaryDark.100',
// Paint on own layer so FLIP transforms don't trigger layout thrash. // Paint on own layer so FLIP transforms don't trigger layout thrash.
willChange: 'transform', willChange: 'transform',
'& .lk-participant-tile': { '& .lk-participant-tile': {
width: '100%', width: '100%',
height: '100%', height: '100%',
}, },
}, },
}) })
@@ -1,87 +1,87 @@
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTracks } from '@livekit/components-react' import { useTracks } from '@livekit/components-react'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { import {
isCameraTrack, isCameraTrack,
pickLocalCameraTrack, pickLocalCameraTrack,
pickRemoteCameraTrack, pickRemoteCameraTrack,
pickScreenShareTrack, pickScreenShareTrack,
} from '../../utils/pipTrackSelection' } from '../../utils/pipTrackSelection'
import { PipFocusLayout } from './PipFocusLayout' import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout' import { PipGridLayout } from './PipGridLayout'
/** /**
* Above this count the PiP stage switches from the focus layout * Above this count the PiP stage switches from the focus layout
* (main + thumbnail) to the adaptive grid layout. * (main + thumbnail) to the adaptive grid layout.
*/ */
const FOCUS_MAX_TILES = 2 const FOCUS_MAX_TILES = 2
// Handles which layout to render inside the PiP stage. // Handles which layout to render inside the PiP stage.
export const PipStage = () => { export const PipStage = () => {
const { t } = useTranslation('rooms', { const { t } = useTranslation('rooms', {
keyPrefix: 'options.items.pictureInPicture', keyPrefix: 'options.items.pictureInPicture',
}) })
const tracks = useTracks( const tracks = useTracks(
[ [
{ source: Track.Source.Camera, withPlaceholder: true }, { source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false }, { source: Track.Source.ScreenShare, withPlaceholder: false },
], ],
{ onlySubscribed: false } { onlySubscribed: false }
) )
const screenShareTrack = useMemo(() => pickScreenShareTrack(tracks), [tracks]) const screenShareTrack = useMemo(() => pickScreenShareTrack(tracks), [tracks])
// Order the list so the "focus target" (screen share when available, // Order the list so the "focus target" (screen share when available,
// otherwise a remote camera) is first. Both layouts consume this order. // otherwise a remote camera) is first. Both layouts consume this order.
const stageTracks = useMemo(() => { const stageTracks = useMemo(() => {
const cameraTracks = tracks.filter(isCameraTrack) const cameraTracks = tracks.filter(isCameraTrack)
if (!screenShareTrack) return cameraTracks if (!screenShareTrack) return cameraTracks
return [screenShareTrack, ...cameraTracks] return [screenShareTrack, ...cameraTracks]
}, [tracks, screenShareTrack]) }, [tracks, screenShareTrack])
// avoid tabbing to the stage when it's not visible // avoid tabbing to the stage when it's not visible
const frameRef = useRef<HTMLDivElement>(null) const frameRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
frameRef.current?.setAttribute('inert', '') frameRef.current?.setAttribute('inert', '')
}, []) }, [])
if (stageTracks.length === 0) return null if (stageTracks.length === 0) return null
const stageLabel = t('stage') const stageLabel = t('stage')
if (stageTracks.length > FOCUS_MAX_TILES) { if (stageTracks.length > FOCUS_MAX_TILES) {
return ( return (
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}> <StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
<PipGridLayout tracks={stageTracks} /> <PipGridLayout tracks={stageTracks} />
</StageFrame> </StageFrame>
) )
} }
const localCameraTrack = pickLocalCameraTrack(stageTracks) const localCameraTrack = pickLocalCameraTrack(stageTracks)
const remoteCameraTrack = pickRemoteCameraTrack(stageTracks) const remoteCameraTrack = pickRemoteCameraTrack(stageTracks)
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? stageTracks[0] const mainTrack = screenShareTrack ?? remoteCameraTrack ?? stageTracks[0]
const thumbnailTrack = const thumbnailTrack =
localCameraTrack && localCameraTrack !== mainTrack localCameraTrack && localCameraTrack !== mainTrack
? localCameraTrack ? localCameraTrack
: stageTracks.find((track) => track !== mainTrack) : stageTracks.find((track) => track !== mainTrack)
return ( return (
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}> <StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} /> <PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
</StageFrame> </StageFrame>
) )
} }
const StageFrame = styled('div', { const StageFrame = styled('div', {
base: { base: {
position: 'relative', position: 'relative',
minWidth: 0, minWidth: 0,
minHeight: 0, minHeight: 0,
margin: '0.5rem', margin: '0.5rem',
borderRadius: '4px', borderRadius: '4px',
overflow: 'hidden', overflow: 'hidden',
}, },
}) })
@@ -0,0 +1,52 @@
import { useConnectionState, useRoomContext } from '@livekit/components-react'
import { ConnectionState } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
/**
* Banner surfaced inside the PiP when the room connection degrades.
*
* Scoped to `Reconnecting` / `Disconnected` - the two states the user needs
* to see while their attention is on the PiP rather than the main window.
*/
export const PipConnectionStateToast = () => {
const room = useRoomContext()
const state = useConnectionState(room)
const { t } = useTranslation('rooms', {
keyPrefix: 'options.items.pictureInPicture.connection',
})
const label =
state === ConnectionState.Reconnecting
? t('reconnecting')
: state === ConnectionState.Disconnected
? t('disconnected')
: null
if (!label) return null
return (
<Banner role="status" aria-live="polite">
{label}
</Banner>
)
}
const Banner = styled('div', {
base: {
position: 'absolute',
top: '0.5rem',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'greyscale.800',
color: 'white',
fontSize: '0.8125rem',
lineHeight: 1.3,
padding: '0.375rem 0.75rem',
borderRadius: '6px',
boxShadow:
'rgba(0, 0, 0, 0.4) 0px 2px 6px 0px, rgba(0, 0, 0, 0.25) 0px 4px 12px 2px',
zIndex: 1001,
animation: 'fade 200ms',
},
})
@@ -0,0 +1,79 @@
import { useToastQueue } from '@react-stately/toast'
import { RiCloseLine } from '@remixicon/react'
import { styled } from '@/styled-system/jsx'
import { Button } from '@/primitives'
import { useTranslation } from 'react-i18next'
import {
toastQueue,
type ToastData,
} from '@/features/notifications/components/ToastProvider'
import { PipToastBody } from './PipToastBody'
/**
* Shows shared toasts in the PiP window.
* We use a local aria-live region so screen readers can read them in PiP.
*/
export const PipNotificationOverlay = () => {
const state = useToastQueue<ToastData>(toastQueue)
const { t } = useTranslation('rooms', {
keyPrefix: 'options.items.pictureInPicture',
})
if (state.visibleToasts.length === 0) return null
return (
<Region
role="region"
aria-label={t('notificationsLabel')}
aria-live="polite"
aria-relevant="additions"
>
{state.visibleToasts.map((toast) => (
<ToastCard key={toast.key} aria-atomic="true">
<PipToastBody toast={toast} />
<Button
square
size="sm"
invisible
aria-label={t('dismissNotification')}
onPress={() => state.close(toast.key)}
>
<RiCloseLine size={16} color="white" aria-hidden="true" />
</Button>
</ToastCard>
))}
</Region>
)
}
const Region = styled('div', {
base: {
position: 'absolute',
top: '0.5rem',
left: '0.5rem',
right: '0.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.375rem',
alignItems: 'center',
pointerEvents: 'none',
zIndex: 1000,
'& > *': { pointerEvents: 'auto' },
},
})
const ToastCard = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
maxWidth: '100%',
backgroundColor: 'greyscale.700',
color: 'white',
borderRadius: '6px',
boxShadow:
'rgba(0, 0, 0, 0.4) 0px 2px 6px 0px, rgba(0, 0, 0, 0.25) 0px 4px 12px 2px',
paddingRight: '0.25rem',
animation: 'fade 200ms',
},
})
@@ -0,0 +1,119 @@
import type { QueuedToast } from '@react-stately/toast'
import { useTranslation } from 'react-i18next'
import { RiHand, RiMessage2Line } from '@remixicon/react'
import type { ReactNode } from 'react'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { NotificationType } from '@/features/notifications/NotificationType'
import type { ToastData } from '@/features/notifications/components/ToastProvider'
import { RecordingMode } from '@/features/recording'
type Props = {
toast: QueuedToast<ToastData>
}
/**
* Renders the toast content used in PiP.
* PiP stays display-only, so main-window actions are not shown here.
*/
export const PipToastBody = ({ toast }: Props) => {
const { t } = useTranslation('notifications')
const { type, participant, message, removedSources } = toast.content
const name = participant?.name || t('defaultName')
switch (type) {
case NotificationType.ParticipantJoined:
return <Line>{t('joined.description', { name })}</Line>
case NotificationType.ParticipantMuted:
return <Line>{t('muted', { name })}</Line>
case NotificationType.HandRaised:
return (
<Line>
<RiHand
size={16}
color="white"
className={iconStyle}
aria-hidden="true"
/>
{t('raised.description', { name })}
</Line>
)
case NotificationType.MessageReceived:
return (
<Line>
<RiMessage2Line
size={16}
color="white"
className={iconStyle}
aria-hidden="true"
/>
<span>
<strong>{name}</strong>
{message ? ` - ${message}` : null}
</span>
</Line>
)
case NotificationType.TranscriptionStarted:
return <Line>{t('transcript.started', { name })}</Line>
case NotificationType.TranscriptionStopped:
return <Line>{t('transcript.stopped', { name })}</Line>
case NotificationType.TranscriptionLimitReached:
return <Line>{t('transcript.limitReached')}</Line>
case NotificationType.TranscriptionRequested:
return <Line>{t('transcript.requested', { name })}</Line>
case NotificationType.ScreenRecordingStarted:
return <Line>{t('screenRecording.started', { name })}</Line>
case NotificationType.ScreenRecordingStopped:
return <Line>{t('screenRecording.stopped', { name })}</Line>
case NotificationType.ScreenRecordingLimitReached:
return <Line>{t('screenRecording.limitReached')}</Line>
case NotificationType.ScreenRecordingRequested:
return <Line>{t('screenRecording.requested', { name })}</Line>
case NotificationType.RecordingSaving: {
const mode = toast.content.mode as RecordingMode | undefined
const key =
mode === RecordingMode.ScreenRecording
? 'recordingSave.screenRecording.default'
: 'recordingSave.transcript.default'
return <Line>{t(key)}</Line>
}
case NotificationType.PermissionsRemoved: {
const key = resolvePermissionsKey(removedSources)
if (!key) return null
return <Line>{t(`permissionsRemoved.${key}`)}</Line>
}
default:
return message ? <Line>{message}</Line> : null
}
}
const resolvePermissionsKey = (sources: unknown): string | null => {
if (!Array.isArray(sources) || sources.length === 0) return null
if (sources.length === 1) return sources[0] as string
if (sources.includes('screen_share')) return 'screen_share'
return null
}
const Line = ({ children }: { children: ReactNode }) => (
<HStack
alignItems="center"
gap="0.5rem"
padding="0.625rem 0.75rem"
className={css({
fontSize: '0.8125rem',
lineHeight: 1.3,
})}
>
{children}
</HStack>
)
const iconStyle = css({ flexShrink: 0 })
+7 -1
View File
@@ -250,7 +250,13 @@
"stage": "Teilnehmer", "stage": "Teilnehmer",
"controlBar": "Besprechungssteuerung", "controlBar": "Besprechungssteuerung",
"previousReactions": "Vorherige Reaktionen", "previousReactions": "Vorherige Reaktionen",
"nextReactions": "Nächste Reaktionen" "nextReactions": "Nächste Reaktionen",
"notificationsLabel": "Benachrichtigungen",
"dismissNotification": "Benachrichtigung schließen",
"connection": {
"reconnecting": "Verbindung wird wiederhergestellt…",
"disconnected": "Verbindung getrennt"
}
}, },
"fullscreen": { "fullscreen": {
"enter": "Vollbild", "enter": "Vollbild",
+7 -1
View File
@@ -250,7 +250,13 @@
"stage": "Participants", "stage": "Participants",
"controlBar": "Meeting controls", "controlBar": "Meeting controls",
"previousReactions": "Previous reactions", "previousReactions": "Previous reactions",
"nextReactions": "Next reactions" "nextReactions": "Next reactions",
"notificationsLabel": "Notifications",
"dismissNotification": "Dismiss notification",
"connection": {
"reconnecting": "Reconnecting…",
"disconnected": "Disconnected"
}
}, },
"fullscreen": { "fullscreen": {
"enter": "Fullscreen", "enter": "Fullscreen",
+7 -1
View File
@@ -250,7 +250,13 @@
"stage": "Participants", "stage": "Participants",
"controlBar": "Commandes de la réunion", "controlBar": "Commandes de la réunion",
"previousReactions": "Réactions précédentes", "previousReactions": "Réactions précédentes",
"nextReactions": "Réactions suivantes" "nextReactions": "Réactions suivantes",
"notificationsLabel": "Notifications",
"dismissNotification": "Fermer la notification",
"connection": {
"reconnecting": "Reconnexion…",
"disconnected": "Déconnecté"
}
}, },
"fullscreen": { "fullscreen": {
"enter": "Plein écran", "enter": "Plein écran",
+7 -1
View File
@@ -250,7 +250,13 @@
"stage": "Deelnemers", "stage": "Deelnemers",
"controlBar": "Vergaderbesturing", "controlBar": "Vergaderbesturing",
"previousReactions": "Vorige reacties", "previousReactions": "Vorige reacties",
"nextReactions": "Volgende reacties" "nextReactions": "Volgende reacties",
"notificationsLabel": "Meldingen",
"dismissNotification": "Melding sluiten",
"connection": {
"reconnecting": "Opnieuw verbinden…",
"disconnected": "Verbinding verbroken"
}
}, },
"fullscreen": { "fullscreen": {
"enter": "Volledig scherm", "enter": "Volledig scherm",