Skip to content

Commit 5ca3976

Browse files
committed
✨(frontend) show room notifications inside the PiP window
Render the shared toast queue in PiP.
1 parent c8ae87b commit 5ca3976

10 files changed

Lines changed: 376 additions & 92 deletions

File tree

src/frontend/src/features/notifications/MainNotificationToast.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect } from 'react'
1+
import { useCallback, useEffect, useRef } from 'react'
22
import { useRoomContext } from '@livekit/components-react'
33
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
44
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
@@ -24,12 +24,18 @@ export const MainNotificationToast = () => {
2424

2525
const { appendReaction } = useReactions()
2626

27+
// Chat uses keepAlive in multiple SidePanels (main + PiP), so the same
28+
// message event can fire more than once. Track the last id to deduplicate.
29+
const lastChatMsgIdRef = useRef<string>('')
30+
2731
useEffect(() => {
2832
const handleChatMessage = (
2933
chatMessage: ChatMessage,
3034
participant?: Participant | undefined
3135
) => {
3236
if (!participant || participant.isLocal) return
37+
if (chatMessage.id && chatMessage.id === lastChatMsgIdRef.current) return
38+
lastChatMsgIdRef.current = chatMessage.id ?? ''
3339
triggerNotificationSound(NotificationType.MessageReceived)
3440
toastQueue.add(
3541
{

src/frontend/src/features/pip/components/PipView.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { usePipRestoreFocus } from '../hooks/usePipRestoreFocus'
1111
import { PipControlBar } from './PipControlBar'
1212
import { PipReactionsToolbar } from './PipReactionsToolbar'
1313
import { PipStage } from './layouts/PipStage'
14+
import { PipNotificationOverlay } from './notifications/PipNotificationOverlay'
15+
import { PipConnectionStateToast } from './notifications/PipConnectionStateToast'
1416

1517
export const PipView = () => {
1618
const browserSupportsScreenSharing = supportsScreenSharing()
@@ -47,6 +49,8 @@ export const PipView = () => {
4749
<PipReactionsToolbar />
4850
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
4951
<SidePanel store={pipLayoutStore} />
52+
<PipConnectionStateToast />
53+
<PipNotificationOverlay />
5054
</PipContainer>
5155
)
5256
}
Lines changed: 87 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,87 @@
1-
import { useEffect, useMemo, useRef } from 'react'
2-
import { useTranslation } from 'react-i18next'
3-
import { useTracks } from '@livekit/components-react'
4-
import { Track } from 'livekit-client'
5-
import { styled } from '@/styled-system/jsx'
6-
import {
7-
isCameraTrack,
8-
pickLocalCameraTrack,
9-
pickRemoteCameraTrack,
10-
pickScreenShareTrack,
11-
} from '../../utils/pipTrackSelection'
12-
import { PipFocusLayout } from './PipFocusLayout'
13-
import { PipGridLayout } from './PipGridLayout'
14-
15-
/**
16-
* Above this count the PiP stage switches from the focus layout
17-
* (main + thumbnail) to the adaptive grid layout.
18-
*/
19-
const FOCUS_MAX_TILES = 2
20-
21-
// Handles which layout to render inside the PiP stage.
22-
23-
export const PipStage = () => {
24-
const { t } = useTranslation('rooms', {
25-
keyPrefix: 'options.items.pictureInPicture',
26-
})
27-
const tracks = useTracks(
28-
[
29-
{ source: Track.Source.Camera, withPlaceholder: true },
30-
{ source: Track.Source.ScreenShare, withPlaceholder: false },
31-
],
32-
{ onlySubscribed: false }
33-
)
34-
35-
const screenShareTrack = useMemo(() => pickScreenShareTrack(tracks), [tracks])
36-
37-
// Order the list so the "focus target" (screen share when available,
38-
// otherwise a remote camera) is first. Both layouts consume this order.
39-
const stageTracks = useMemo(() => {
40-
const cameraTracks = tracks.filter(isCameraTrack)
41-
if (!screenShareTrack) return cameraTracks
42-
return [screenShareTrack, ...cameraTracks]
43-
}, [tracks, screenShareTrack])
44-
45-
// avoid tabbing to the stage when it's not visible
46-
const frameRef = useRef<HTMLDivElement>(null)
47-
useEffect(() => {
48-
frameRef.current?.setAttribute('inert', '')
49-
}, [])
50-
51-
if (stageTracks.length === 0) return null
52-
53-
const stageLabel = t('stage')
54-
55-
if (stageTracks.length > FOCUS_MAX_TILES) {
56-
return (
57-
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
58-
<PipGridLayout tracks={stageTracks} />
59-
</StageFrame>
60-
)
61-
}
62-
63-
const localCameraTrack = pickLocalCameraTrack(stageTracks)
64-
const remoteCameraTrack = pickRemoteCameraTrack(stageTracks)
65-
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? stageTracks[0]
66-
const thumbnailTrack =
67-
localCameraTrack && localCameraTrack !== mainTrack
68-
? localCameraTrack
69-
: stageTracks.find((track) => track !== mainTrack)
70-
71-
return (
72-
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
73-
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
74-
</StageFrame>
75-
)
76-
}
77-
78-
const StageFrame = styled('div', {
79-
base: {
80-
position: 'relative',
81-
minWidth: 0,
82-
minHeight: 0,
83-
margin: '0.5rem',
84-
borderRadius: '4px',
85-
overflow: 'hidden',
86-
},
87-
})
1+
import { useEffect, useMemo, useRef } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import { useTracks } from '@livekit/components-react'
4+
import { Track } from 'livekit-client'
5+
import { styled } from '@/styled-system/jsx'
6+
import {
7+
isCameraTrack,
8+
pickLocalCameraTrack,
9+
pickRemoteCameraTrack,
10+
pickScreenShareTrack,
11+
} from '../../utils/pipTrackSelection'
12+
import { PipFocusLayout } from './PipFocusLayout'
13+
import { PipGridLayout } from './PipGridLayout'
14+
15+
/**
16+
* Above this count the PiP stage switches from the focus layout
17+
* (main + thumbnail) to the adaptive grid layout.
18+
*/
19+
const FOCUS_MAX_TILES = 2
20+
21+
// Handles which layout to render inside the PiP stage.
22+
23+
export const PipStage = () => {
24+
const { t } = useTranslation('rooms', {
25+
keyPrefix: 'options.items.pictureInPicture',
26+
})
27+
const tracks = useTracks(
28+
[
29+
{ source: Track.Source.Camera, withPlaceholder: true },
30+
{ source: Track.Source.ScreenShare, withPlaceholder: false },
31+
],
32+
{ onlySubscribed: false }
33+
)
34+
35+
const screenShareTrack = useMemo(() => pickScreenShareTrack(tracks), [tracks])
36+
37+
// Order the list so the "focus target" (screen share when available,
38+
// otherwise a remote camera) is first. Both layouts consume this order.
39+
const stageTracks = useMemo(() => {
40+
const cameraTracks = tracks.filter(isCameraTrack)
41+
if (!screenShareTrack) return cameraTracks
42+
return [screenShareTrack, ...cameraTracks]
43+
}, [tracks, screenShareTrack])
44+
45+
// avoid tabbing to the stage when it's not visible
46+
const frameRef = useRef<HTMLDivElement>(null)
47+
useEffect(() => {
48+
frameRef.current?.setAttribute('inert', '')
49+
}, [])
50+
51+
if (stageTracks.length === 0) return null
52+
53+
const stageLabel = t('stage')
54+
55+
if (stageTracks.length > FOCUS_MAX_TILES) {
56+
return (
57+
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
58+
<PipGridLayout tracks={stageTracks} />
59+
</StageFrame>
60+
)
61+
}
62+
63+
const localCameraTrack = pickLocalCameraTrack(stageTracks)
64+
const remoteCameraTrack = pickRemoteCameraTrack(stageTracks)
65+
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? stageTracks[0]
66+
const thumbnailTrack =
67+
localCameraTrack && localCameraTrack !== mainTrack
68+
? localCameraTrack
69+
: stageTracks.find((track) => track !== mainTrack)
70+
71+
return (
72+
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
73+
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
74+
</StageFrame>
75+
)
76+
}
77+
78+
const StageFrame = styled('div', {
79+
base: {
80+
position: 'relative',
81+
minWidth: 0,
82+
minHeight: 0,
83+
margin: '0.5rem',
84+
borderRadius: '4px',
85+
overflow: 'hidden',
86+
},
87+
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useConnectionState, useRoomContext } from '@livekit/components-react'
2+
import { ConnectionState } from 'livekit-client'
3+
import { useTranslation } from 'react-i18next'
4+
import { styled } from '@/styled-system/jsx'
5+
6+
/**
7+
* Banner surfaced inside the PiP when the room connection degrades.
8+
*
9+
* Scoped to `Reconnecting` / `Disconnected` - the two states the user needs
10+
* to see while their attention is on the PiP rather than the main window.
11+
*/
12+
export const PipConnectionStateToast = () => {
13+
const room = useRoomContext()
14+
const state = useConnectionState(room)
15+
const { t } = useTranslation('rooms', {
16+
keyPrefix: 'options.items.pictureInPicture.connection',
17+
})
18+
19+
const label =
20+
state === ConnectionState.Reconnecting
21+
? t('reconnecting')
22+
: state === ConnectionState.Disconnected
23+
? t('disconnected')
24+
: null
25+
26+
if (!label) return null
27+
28+
return (
29+
<Banner role="status" aria-live="polite">
30+
{label}
31+
</Banner>
32+
)
33+
}
34+
35+
const Banner = styled('div', {
36+
base: {
37+
position: 'absolute',
38+
top: '0.5rem',
39+
left: '50%',
40+
transform: 'translateX(-50%)',
41+
backgroundColor: 'greyscale.800',
42+
color: 'white',
43+
fontSize: '0.8125rem',
44+
lineHeight: 1.3,
45+
padding: '0.375rem 0.75rem',
46+
borderRadius: '6px',
47+
boxShadow:
48+
'rgba(0, 0, 0, 0.4) 0px 2px 6px 0px, rgba(0, 0, 0, 0.25) 0px 4px 12px 2px',
49+
zIndex: 1001,
50+
animation: 'fade 200ms',
51+
},
52+
})
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { useToastQueue } from '@react-stately/toast'
2+
import { RiCloseLine } from '@remixicon/react'
3+
import { styled } from '@/styled-system/jsx'
4+
import { Button } from '@/primitives'
5+
import { useTranslation } from 'react-i18next'
6+
import {
7+
toastQueue,
8+
type ToastData,
9+
} from '@/features/notifications/components/ToastProvider'
10+
import { PipToastBody } from './PipToastBody'
11+
12+
/**
13+
* Shows shared toasts in the PiP window.
14+
* We use a local aria-live region so screen readers can read them in PiP.
15+
*/
16+
export const PipNotificationOverlay = () => {
17+
const state = useToastQueue<ToastData>(toastQueue)
18+
const { t } = useTranslation('rooms', {
19+
keyPrefix: 'options.items.pictureInPicture',
20+
})
21+
22+
if (state.visibleToasts.length === 0) return null
23+
24+
return (
25+
<Region
26+
role="region"
27+
aria-label={t('notificationsLabel')}
28+
aria-live="polite"
29+
aria-relevant="additions"
30+
>
31+
{state.visibleToasts.map((toast) => (
32+
<ToastCard key={toast.key} aria-atomic="true">
33+
<PipToastBody toast={toast} />
34+
<Button
35+
square
36+
size="sm"
37+
invisible
38+
aria-label={t('dismissNotification')}
39+
onPress={() => state.close(toast.key)}
40+
>
41+
<RiCloseLine size={16} color="white" aria-hidden="true" />
42+
</Button>
43+
</ToastCard>
44+
))}
45+
</Region>
46+
)
47+
}
48+
49+
const Region = styled('div', {
50+
base: {
51+
position: 'absolute',
52+
top: '0.5rem',
53+
left: '0.5rem',
54+
right: '0.5rem',
55+
display: 'flex',
56+
flexDirection: 'column',
57+
gap: '0.375rem',
58+
alignItems: 'center',
59+
pointerEvents: 'none',
60+
zIndex: 1000,
61+
'& > *': { pointerEvents: 'auto' },
62+
},
63+
})
64+
65+
const ToastCard = styled('div', {
66+
base: {
67+
display: 'flex',
68+
alignItems: 'center',
69+
gap: '0.25rem',
70+
maxWidth: '100%',
71+
backgroundColor: 'greyscale.700',
72+
color: 'white',
73+
borderRadius: '6px',
74+
boxShadow:
75+
'rgba(0, 0, 0, 0.4) 0px 2px 6px 0px, rgba(0, 0, 0, 0.25) 0px 4px 12px 2px',
76+
paddingRight: '0.25rem',
77+
animation: 'fade 200ms',
78+
},
79+
})

0 commit comments

Comments
 (0)