diff --git a/src/frontend/src/features/notifications/MainNotificationToast.tsx b/src/frontend/src/features/notifications/MainNotificationToast.tsx index 6e5ea93c..bc17d81e 100644 --- a/src/frontend/src/features/notifications/MainNotificationToast.tsx +++ b/src/frontend/src/features/notifications/MainNotificationToast.tsx @@ -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('') + 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( { diff --git a/src/frontend/src/features/pip/components/DocumentPiPPortal.tsx b/src/frontend/src/features/pip/components/DocumentPiPPortal.tsx index 52326e65..793e18fd 100644 --- a/src/frontend/src/features/pip/components/DocumentPiPPortal.tsx +++ b/src/frontend/src/features/pip/components/DocumentPiPPortal.tsx @@ -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(() => { diff --git a/src/frontend/src/features/pip/components/PipView.tsx b/src/frontend/src/features/pip/components/PipView.tsx index bfe1026b..92e39d6d 100644 --- a/src/frontend/src/features/pip/components/PipView.tsx +++ b/src/frontend/src/features/pip/components/PipView.tsx @@ -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 = () => { + + ) } diff --git a/src/frontend/src/features/pip/components/layouts/PipFocusLayout.tsx b/src/frontend/src/features/pip/components/layouts/PipFocusLayout.tsx index 9cd10aea..5cd54202 100644 --- a/src/frontend/src/features/pip/components/layouts/PipFocusLayout.tsx +++ b/src/frontend/src/features/pip/components/layouts/PipFocusLayout.tsx @@ -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 ( - - - - - {thumbnailTrack && ( - - - - )} - - ) -}) - -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 ( + + + + + {thumbnailTrack && ( + + + + )} + + ) +}) + +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, + }, +}) diff --git a/src/frontend/src/features/pip/components/layouts/PipGridLayout.tsx b/src/frontend/src/features/pip/components/layouts/PipGridLayout.tsx index 350f86cd..1c0080bb 100644 --- a/src/frontend/src/features/pip/components/layouts/PipGridLayout.tsx +++ b/src/frontend/src/features/pip/components/layouts/PipGridLayout.tsx @@ -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