mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-24 08:56:28 +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 { 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 })
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user