mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 11:58:53 +00:00
✨(frontend) show room notifications inside the PiP window
Render the shared toast queue in PiP.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||
@@ -24,12 +24,18 @@ export const MainNotificationToast = () => {
|
||||
|
||||
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(() => {
|
||||
const handleChatMessage = (
|
||||
chatMessage: ChatMessage,
|
||||
participant?: Participant | undefined
|
||||
) => {
|
||||
if (!participant || participant.isLocal) return
|
||||
if (chatMessage.id && chatMessage.id === lastChatMsgIdRef.current) return
|
||||
lastChatMsgIdRef.current = chatMessage.id ?? ''
|
||||
triggerNotificationSound(NotificationType.MessageReceived)
|
||||
toastQueue.add(
|
||||
{
|
||||
|
||||
@@ -170,7 +170,7 @@ export const DocumentPiPPortal = ({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [closePiP, isOpen, isSupported, openPiP])
|
||||
}, [closePiP, isOpen, isSupported, openPiP, t])
|
||||
|
||||
// Focus stays on the trigger; PiP is announced as an auxiliary surface.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { usePipRestoreFocus } from '../hooks/usePipRestoreFocus'
|
||||
import { PipControlBar } from './PipControlBar'
|
||||
import { PipReactionsToolbar } from './PipReactionsToolbar'
|
||||
import { PipStage } from './layouts/PipStage'
|
||||
import { PipNotificationOverlay } from './notifications/PipNotificationOverlay'
|
||||
import { PipConnectionStateToast } from './notifications/PipConnectionStateToast'
|
||||
|
||||
export const PipView = () => {
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
@@ -47,6 +49,8 @@ export const PipView = () => {
|
||||
<PipReactionsToolbar />
|
||||
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
|
||||
<SidePanel store={pipLayoutStore} />
|
||||
<PipConnectionStateToast />
|
||||
<PipNotificationOverlay />
|
||||
</PipContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,81 +1,81 @@
|
||||
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 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(function PipFocusLayout({
|
||||
mainTrack,
|
||||
thumbnailTrack,
|
||||
}: PipFocusLayoutProps) {
|
||||
return (
|
||||
<FocusContainer>
|
||||
<MainSlot>
|
||||
<ParticipantTile
|
||||
key={getTrackKey(mainTrack)}
|
||||
trackRef={mainTrack}
|
||||
disableMetadata
|
||||
/>
|
||||
</MainSlot>
|
||||
{thumbnailTrack && (
|
||||
<Thumbnail>
|
||||
<ParticipantTile
|
||||
key={getTrackKey(thumbnailTrack)}
|
||||
trackRef={thumbnailTrack}
|
||||
disableMetadata
|
||||
/>
|
||||
</Thumbnail>
|
||||
)}
|
||||
</FocusContainer>
|
||||
)
|
||||
})
|
||||
|
||||
const FocusContainer = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
},
|
||||
})
|
||||
|
||||
const MainSlot = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
'& .lk-participant-media-video': {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const Thumbnail = styled('div', {
|
||||
base: {
|
||||
position: 'absolute',
|
||||
right: '1rem',
|
||||
bottom: '1rem',
|
||||
width: '42%',
|
||||
maxWidth: '220px',
|
||||
minWidth: '140px',
|
||||
aspectRatio: '16 / 9',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'md',
|
||||
zIndex: 2,
|
||||
},
|
||||
})
|
||||
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 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(function PipFocusLayout({
|
||||
mainTrack,
|
||||
thumbnailTrack,
|
||||
}: PipFocusLayoutProps) {
|
||||
return (
|
||||
<FocusContainer>
|
||||
<MainSlot>
|
||||
<ParticipantTile
|
||||
key={getTrackKey(mainTrack)}
|
||||
trackRef={mainTrack}
|
||||
disableMetadata
|
||||
/>
|
||||
</MainSlot>
|
||||
{thumbnailTrack && (
|
||||
<Thumbnail>
|
||||
<ParticipantTile
|
||||
key={getTrackKey(thumbnailTrack)}
|
||||
trackRef={thumbnailTrack}
|
||||
disableMetadata
|
||||
/>
|
||||
</Thumbnail>
|
||||
)}
|
||||
</FocusContainer>
|
||||
)
|
||||
})
|
||||
|
||||
const FocusContainer = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
},
|
||||
})
|
||||
|
||||
const MainSlot = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
'& .lk-participant-media-video': {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const Thumbnail = styled('div', {
|
||||
base: {
|
||||
position: 'absolute',
|
||||
right: '1rem',
|
||||
bottom: '1rem',
|
||||
width: '42%',
|
||||
maxWidth: '220px',
|
||||
minWidth: '140px',
|
||||
aspectRatio: '16 / 9',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'md',
|
||||
zIndex: 2,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,82 +1,82 @@
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
||||
import { usePipFlipAnimations } from '../../hooks/usePipFlipAnimations'
|
||||
import { computePipGridLayout } from '../../utils/pipGrid'
|
||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||
|
||||
type PipGridLayoutProps = {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Adaptive grid used when 3+ tracks are visible in the PiP window.
|
||||
*
|
||||
* All grid math (shape choice + partial-row stretching) is delegated to
|
||||
* `computePipGridLayout`. This component only measures the container,
|
||||
* applies the returned placements, and plays a FLIP animation when the
|
||||
* tile set or grid shape changes (participant joins/leaves or shape shift).
|
||||
*
|
||||
* Tiles keep a stable key so resizing never remounts <video> elements.
|
||||
*/
|
||||
export const PipGridLayout = memo(function PipGridLayout({
|
||||
tracks,
|
||||
}: PipGridLayoutProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { width, height } = usePipElementSize(containerRef)
|
||||
|
||||
const tileKeys = useMemo(() => tracks.map(getTrackKey), [tracks])
|
||||
|
||||
const { rows, subColumns, placements } = useMemo(
|
||||
() => computePipGridLayout(tracks.length, width, height),
|
||||
[tracks.length, width, height]
|
||||
)
|
||||
|
||||
const gridStyle = useMemo(
|
||||
() => ({
|
||||
gridTemplateColumns: `repeat(${subColumns}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
|
||||
}),
|
||||
[subColumns, rows]
|
||||
)
|
||||
|
||||
usePipFlipAnimations(containerRef, tileKeys)
|
||||
|
||||
return (
|
||||
<GridContainer ref={containerRef} style={gridStyle}>
|
||||
{tracks.map((track, index) => (
|
||||
<GridCell key={tileKeys[index]} style={placements[index]}>
|
||||
<ParticipantTile trackRef={track} disableMetadata />
|
||||
</GridCell>
|
||||
))}
|
||||
</GridContainer>
|
||||
)
|
||||
})
|
||||
|
||||
const GridContainer = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'grid',
|
||||
gap: '0.25rem',
|
||||
},
|
||||
})
|
||||
|
||||
const GridCell = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
// Paint on own layer so FLIP transforms don't trigger layout thrash.
|
||||
willChange: 'transform',
|
||||
'& .lk-participant-tile': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
},
|
||||
})
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
||||
import { usePipFlipAnimations } from '../../hooks/usePipFlipAnimations'
|
||||
import { computePipGridLayout } from '../../utils/pipGrid'
|
||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||
|
||||
type PipGridLayoutProps = {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Adaptive grid used when 3+ tracks are visible in the PiP window.
|
||||
*
|
||||
* All grid math (shape choice + partial-row stretching) is delegated to
|
||||
* `computePipGridLayout`. This component only measures the container,
|
||||
* applies the returned placements, and plays a FLIP animation when the
|
||||
* tile set or grid shape changes (participant joins/leaves or shape shift).
|
||||
*
|
||||
* Tiles keep a stable key so resizing never remounts <video> elements.
|
||||
*/
|
||||
export const PipGridLayout = memo(function PipGridLayout({
|
||||
tracks,
|
||||
}: PipGridLayoutProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { width, height } = usePipElementSize(containerRef)
|
||||
|
||||
const tileKeys = useMemo(() => tracks.map(getTrackKey), [tracks])
|
||||
|
||||
const { rows, subColumns, placements } = useMemo(
|
||||
() => computePipGridLayout(tracks.length, width, height),
|
||||
[tracks.length, width, height]
|
||||
)
|
||||
|
||||
const gridStyle = useMemo(
|
||||
() => ({
|
||||
gridTemplateColumns: `repeat(${subColumns}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
|
||||
}),
|
||||
[subColumns, rows]
|
||||
)
|
||||
|
||||
usePipFlipAnimations(containerRef, tileKeys)
|
||||
|
||||
return (
|
||||
<GridContainer ref={containerRef} style={gridStyle}>
|
||||
{tracks.map((track, index) => (
|
||||
<GridCell key={tileKeys[index]} style={placements[index]}>
|
||||
<ParticipantTile trackRef={track} disableMetadata />
|
||||
</GridCell>
|
||||
))}
|
||||
</GridContainer>
|
||||
)
|
||||
})
|
||||
|
||||
const GridContainer = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'grid',
|
||||
gap: '0.25rem',
|
||||
},
|
||||
})
|
||||
|
||||
const GridCell = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
// Paint on own layer so FLIP transforms don't trigger layout thrash.
|
||||
willChange: 'transform',
|
||||
'& .lk-participant-tile': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,87 +1,87 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTracks } from '@livekit/components-react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import {
|
||||
isCameraTrack,
|
||||
pickLocalCameraTrack,
|
||||
pickRemoteCameraTrack,
|
||||
pickScreenShareTrack,
|
||||
} from '../../utils/pipTrackSelection'
|
||||
import { PipFocusLayout } from './PipFocusLayout'
|
||||
import { PipGridLayout } from './PipGridLayout'
|
||||
|
||||
/**
|
||||
* Above this count the PiP stage switches from the focus layout
|
||||
* (main + thumbnail) to the adaptive grid layout.
|
||||
*/
|
||||
const FOCUS_MAX_TILES = 2
|
||||
|
||||
// Handles which layout to render inside the PiP stage.
|
||||
|
||||
export const PipStage = () => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture',
|
||||
})
|
||||
const tracks = useTracks(
|
||||
[
|
||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||
],
|
||||
{ onlySubscribed: false }
|
||||
)
|
||||
|
||||
const screenShareTrack = useMemo(() => pickScreenShareTrack(tracks), [tracks])
|
||||
|
||||
// Order the list so the "focus target" (screen share when available,
|
||||
// otherwise a remote camera) is first. Both layouts consume this order.
|
||||
const stageTracks = useMemo(() => {
|
||||
const cameraTracks = tracks.filter(isCameraTrack)
|
||||
if (!screenShareTrack) return cameraTracks
|
||||
return [screenShareTrack, ...cameraTracks]
|
||||
}, [tracks, screenShareTrack])
|
||||
|
||||
// avoid tabbing to the stage when it's not visible
|
||||
const frameRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
frameRef.current?.setAttribute('inert', '')
|
||||
}, [])
|
||||
|
||||
if (stageTracks.length === 0) return null
|
||||
|
||||
const stageLabel = t('stage')
|
||||
|
||||
if (stageTracks.length > FOCUS_MAX_TILES) {
|
||||
return (
|
||||
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
|
||||
<PipGridLayout tracks={stageTracks} />
|
||||
</StageFrame>
|
||||
)
|
||||
}
|
||||
|
||||
const localCameraTrack = pickLocalCameraTrack(stageTracks)
|
||||
const remoteCameraTrack = pickRemoteCameraTrack(stageTracks)
|
||||
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? stageTracks[0]
|
||||
const thumbnailTrack =
|
||||
localCameraTrack && localCameraTrack !== mainTrack
|
||||
? localCameraTrack
|
||||
: stageTracks.find((track) => track !== mainTrack)
|
||||
|
||||
return (
|
||||
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
|
||||
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
|
||||
</StageFrame>
|
||||
)
|
||||
}
|
||||
|
||||
const StageFrame = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
margin: '0.5rem',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
})
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTracks } from '@livekit/components-react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import {
|
||||
isCameraTrack,
|
||||
pickLocalCameraTrack,
|
||||
pickRemoteCameraTrack,
|
||||
pickScreenShareTrack,
|
||||
} from '../../utils/pipTrackSelection'
|
||||
import { PipFocusLayout } from './PipFocusLayout'
|
||||
import { PipGridLayout } from './PipGridLayout'
|
||||
|
||||
/**
|
||||
* Above this count the PiP stage switches from the focus layout
|
||||
* (main + thumbnail) to the adaptive grid layout.
|
||||
*/
|
||||
const FOCUS_MAX_TILES = 2
|
||||
|
||||
// Handles which layout to render inside the PiP stage.
|
||||
|
||||
export const PipStage = () => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture',
|
||||
})
|
||||
const tracks = useTracks(
|
||||
[
|
||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||
],
|
||||
{ onlySubscribed: false }
|
||||
)
|
||||
|
||||
const screenShareTrack = useMemo(() => pickScreenShareTrack(tracks), [tracks])
|
||||
|
||||
// Order the list so the "focus target" (screen share when available,
|
||||
// otherwise a remote camera) is first. Both layouts consume this order.
|
||||
const stageTracks = useMemo(() => {
|
||||
const cameraTracks = tracks.filter(isCameraTrack)
|
||||
if (!screenShareTrack) return cameraTracks
|
||||
return [screenShareTrack, ...cameraTracks]
|
||||
}, [tracks, screenShareTrack])
|
||||
|
||||
// avoid tabbing to the stage when it's not visible
|
||||
const frameRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
frameRef.current?.setAttribute('inert', '')
|
||||
}, [])
|
||||
|
||||
if (stageTracks.length === 0) return null
|
||||
|
||||
const stageLabel = t('stage')
|
||||
|
||||
if (stageTracks.length > FOCUS_MAX_TILES) {
|
||||
return (
|
||||
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
|
||||
<PipGridLayout tracks={stageTracks} />
|
||||
</StageFrame>
|
||||
)
|
||||
}
|
||||
|
||||
const localCameraTrack = pickLocalCameraTrack(stageTracks)
|
||||
const remoteCameraTrack = pickRemoteCameraTrack(stageTracks)
|
||||
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? stageTracks[0]
|
||||
const thumbnailTrack =
|
||||
localCameraTrack && localCameraTrack !== mainTrack
|
||||
? localCameraTrack
|
||||
: stageTracks.find((track) => track !== mainTrack)
|
||||
|
||||
return (
|
||||
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
|
||||
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
|
||||
</StageFrame>
|
||||
)
|
||||
}
|
||||
|
||||
const StageFrame = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
margin: '0.5rem',
|
||||
borderRadius: '4px',
|
||||
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 })
|
||||
@@ -250,7 +250,13 @@
|
||||
"stage": "Teilnehmer",
|
||||
"controlBar": "Besprechungssteuerung",
|
||||
"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": {
|
||||
"enter": "Vollbild",
|
||||
|
||||
@@ -250,7 +250,13 @@
|
||||
"stage": "Participants",
|
||||
"controlBar": "Meeting controls",
|
||||
"previousReactions": "Previous reactions",
|
||||
"nextReactions": "Next reactions"
|
||||
"nextReactions": "Next reactions",
|
||||
"notificationsLabel": "Notifications",
|
||||
"dismissNotification": "Dismiss notification",
|
||||
"connection": {
|
||||
"reconnecting": "Reconnecting…",
|
||||
"disconnected": "Disconnected"
|
||||
}
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Fullscreen",
|
||||
|
||||
@@ -250,7 +250,13 @@
|
||||
"stage": "Participants",
|
||||
"controlBar": "Commandes de la réunion",
|
||||
"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": {
|
||||
"enter": "Plein écran",
|
||||
|
||||
@@ -250,7 +250,13 @@
|
||||
"stage": "Deelnemers",
|
||||
"controlBar": "Vergaderbesturing",
|
||||
"previousReactions": "Vorige reacties",
|
||||
"nextReactions": "Volgende reacties"
|
||||
"nextReactions": "Volgende reacties",
|
||||
"notificationsLabel": "Meldingen",
|
||||
"dismissNotification": "Melding sluiten",
|
||||
"connection": {
|
||||
"reconnecting": "Opnieuw verbinden…",
|
||||
"disconnected": "Verbinding verbroken"
|
||||
}
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Volledig scherm",
|
||||
|
||||
Reference in New Issue
Block a user