mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 20:08:24 +00:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eaa3a2a284 | |||
| cdc5add2f3 | |||
| 8793226427 | |||
| 357a989b9d | |||
| b8f3c0aa59 | |||
| 37a3d6e7ed | |||
| afa9d36329 | |||
| 3d81110f56 | |||
| 5a508aeb31 | |||
| f9ca489b2f | |||
| 47ec78973b | |||
| c239a7f2e5 | |||
| 87f8d4ca7e | |||
| 45b939da5c | |||
| 5a6654b3a8 | |||
| afaaec4be1 | |||
| 184e957565 | |||
| 323a998721 | |||
| 2b50dd3158 | |||
| 852789cec6 | |||
| f38dec6a93 | |||
| d8548ec9bb | |||
| 775c8ac073 | |||
| 90ef4ce025 | |||
| 8b1d200740 | |||
| f6510b7488 | |||
| 640205fd9e | |||
| 3c04d1e9a7 | |||
| bbc3d18309 | |||
| 61877d23bc | |||
| a6d20e2f58 | |||
| 1e21274237 | |||
| 6b566831a2 | |||
| b57e11bf51 | |||
| 2378af9e9b | |||
| 30107882dd | |||
| 4f25a1d983 | |||
| 2c02a26563 | |||
| 3eb694b6dd | |||
| 9b2e7905fc | |||
| e1c6fb10f3 | |||
| 04ceb5d568 | |||
| 1771527c2d | |||
| 63a949e420 | |||
| 69135feba5 | |||
| 57a27de8e7 | |||
| 09bb4daebc | |||
| 3e3d9f31a5 | |||
| 439f7b3746 | |||
| fa6967635d | |||
| 07e172041f | |||
| 8c93d26d48 | |||
| a2c55f2305 | |||
| 2f1d102b44 | |||
| e9634b7610 | |||
| 4697cbc875 | |||
| 8c3fb63724 |
@@ -14,6 +14,7 @@ and this project adheres to
|
||||
- ✨(helm) add support multiple transcribe worker / endpoint #1247
|
||||
- ✨(backend) make LiveKit Egress recording encoding configurable #1288
|
||||
- ✨(summary) add speaker-to-participant assignment
|
||||
- ✨(feat) Introduce Picture-in-Picture (PiP) #890
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import type * as React from 'react';
|
||||
|
||||
declare module '@react-aria/overlays' {
|
||||
export type PortalProviderContextValue = {
|
||||
getContainer: () => HTMLElement | null;
|
||||
};
|
||||
|
||||
export type PortalProviderProps = {
|
||||
getContainer: () => HTMLElement | null;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function useUNSAFE_PortalContext(): PortalProviderContextValue;
|
||||
export function UNSAFE_PortalProvider(
|
||||
props: PortalProviderProps,
|
||||
): JSX.Element;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<svg width="102" height="72" viewBox="0 0 102 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_13227_1168)">
|
||||
<g filter="url(#filter0_d_13227_1168)">
|
||||
<rect x="16" y="8.77759" width="51.852" height="41.4816" rx="7.71815" fill="#969EB0"/>
|
||||
<rect x="16" y="8.77759" width="51.852" height="41.4816" rx="7.71815" fill="#181B24" fill-opacity="0.7"/>
|
||||
<rect x="16.5963" y="9.37389" width="50.6595" height="40.289" rx="7.12186" stroke="#969EB0" stroke-width="1.1926"/>
|
||||
<rect x="16.5963" y="9.37389" width="50.6595" height="40.289" rx="7.12186" stroke="#181B24" stroke-opacity="0.6" stroke-width="1.1926"/>
|
||||
<g filter="url(#filter1_d_13227_1168)">
|
||||
<rect x="21.1851" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
|
||||
<rect x="21.1851" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_d_13227_1168)">
|
||||
<rect x="21.1851" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
|
||||
<rect x="21.1851" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
|
||||
</g>
|
||||
<g filter="url(#filter3_d_13227_1168)">
|
||||
<rect x="43.2222" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
|
||||
<rect x="43.2222" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
|
||||
</g>
|
||||
<g filter="url(#filter4_d_13227_1168)">
|
||||
<rect x="43.2222" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
|
||||
<rect x="43.2222" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
<g filter="url(#filter5_d_13227_1168)">
|
||||
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="#7E98FF"/>
|
||||
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="#181B24" fill-opacity="0.7"/>
|
||||
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="url(#paint0_linear_13227_1168)" fill-opacity="0.05"/>
|
||||
<rect x="52.8927" y="35.2999" width="32.5112" height="27.326" rx="7.12186" stroke="#7E98FF" stroke-width="1.1926"/>
|
||||
<rect x="52.8927" y="35.2999" width="32.5112" height="27.326" rx="7.12186" stroke="#181B24" stroke-opacity="0.45" stroke-width="1.1926"/>
|
||||
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 65.2593 54.1482)" fill="#7E98FF"/>
|
||||
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 65.2593 54.1482)" fill="#181B24" fill-opacity="0.45"/>
|
||||
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 71.7407 54.1482)" fill="#7E98FF"/>
|
||||
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 71.7407 54.1482)" fill="#181B24" fill-opacity="0.45"/>
|
||||
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 78.2222 54.1482)" fill="#FF706E"/>
|
||||
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 78.2222 54.1482)" fill="#181B24" fill-opacity="0.45"/>
|
||||
</g>
|
||||
<g filter="url(#filter6_d_13227_1168)">
|
||||
<path d="M50.3653 53.4428C51.0628 53.4428 51.6941 53.2682 52.2593 52.9191C52.8304 52.576 53.2844 52.1135 53.6211 51.5316C53.9638 50.9558 54.1352 50.3156 54.1352 49.6112C54.1352 48.9006 53.9638 48.2543 53.6211 47.6724C53.2844 47.0965 52.8304 46.634 52.2593 46.2849C51.6941 45.9418 51.0628 45.7703 50.3653 45.7703H47.344C46.1836 45.7703 45.1555 45.6539 44.2596 45.4211C43.3637 45.1884 42.5731 44.7871 41.8876 44.2174C41.2022 43.6539 40.598 42.8698 40.0749 41.8652C39.9185 41.5711 39.7412 41.3843 39.5428 41.3046C39.3504 41.225 39.158 41.1852 38.9656 41.1852C38.7251 41.1852 38.5086 41.2924 38.3162 41.5068C38.1298 41.7151 38.0366 42.0183 38.0366 42.4165C38.0366 44.1133 38.217 45.6386 38.5777 46.9924C38.9445 48.3523 39.5037 49.5131 40.2552 50.4749C41.0128 51.4366 41.9778 52.1717 43.1503 52.6801C44.3287 53.1886 45.7266 53.4428 47.344 53.4428H50.3653ZM47.6056 42.2603V56.9161C47.6056 57.2224 47.7048 57.4858 47.9032 57.7063C48.1076 57.9268 48.3692 58.0371 48.6878 58.0371C48.9043 58.0371 49.0997 57.985 49.274 57.8809C49.4544 57.7829 49.6649 57.6175 49.9054 57.3847L57.0212 50.6035C57.1955 50.4381 57.3158 50.2697 57.3819 50.0981C57.4481 49.9266 57.4811 49.7643 57.4811 49.6112C57.4811 49.4641 57.4481 49.3049 57.3819 49.1333C57.3158 48.9618 57.1955 48.7934 57.0212 48.628L49.9054 41.7825C49.6889 41.5742 49.4815 41.4241 49.2831 41.3322C49.0907 41.2342 48.8862 41.1852 48.6698 41.1852C48.3631 41.1852 48.1076 41.2863 47.9032 41.4884C47.7048 41.6906 47.6056 41.9478 47.6056 42.2603Z" fill="#969EB0"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_13227_1168" x="7.82784" y="4.69151" width="68.1964" height="57.826" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="4.08608"/>
|
||||
<feGaussianBlur stdDeviation="4.08608"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d_13227_1168" x="17.3569" y="10.1348" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="1.91407"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter2_d_13227_1168" x="17.3569" y="26.9868" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="1.91407"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter3_d_13227_1168" x="39.394" y="10.1348" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="1.91407"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter4_d_13227_1168" x="39.394" y="26.9868" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="1.91407"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter5_d_13227_1168" x="44.1242" y="30.6175" width="50.0479" height="44.8629" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="4.08608"/>
|
||||
<feGaussianBlur stdDeviation="4.08608"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter6_d_13227_1168" x="29.8645" y="37.0992" width="35.7887" height="33.1961" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="4.08608"/>
|
||||
<feGaussianBlur stdDeviation="4.08608"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_13227_1168" x1="69.1483" y1="34.7036" x2="69.1483" y2="63.2222" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F6F8F9" stop-opacity="0.975"/>
|
||||
<stop offset="1" stop-color="#F6F8F9" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_13227_1168">
|
||||
<rect width="102" height="72" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
@@ -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'
|
||||
@@ -16,6 +16,10 @@ import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { Emoji } from '@/features/reactions/types'
|
||||
import { useReactions } from '@/features/reactions/hooks/useReactions'
|
||||
|
||||
// Sliding window of recent chat ids kept for deduplication. Sized to comfortably
|
||||
// cover bursts and re-emits while staying negligible in memory.
|
||||
const MAX_TRACKED_CHAT_IDS = 16
|
||||
|
||||
export const MainNotificationToast = () => {
|
||||
const room = useRoomContext()
|
||||
const { triggerNotificationSound } = useNotificationSound()
|
||||
@@ -24,12 +28,23 @@ export const MainNotificationToast = () => {
|
||||
|
||||
const { appendReaction } = useReactions()
|
||||
|
||||
// Multiple Chat instances may re-emit the same RoomEvent.ChatMessage.
|
||||
// Dedupe against a small ring of recent ids.
|
||||
const seenChatMsgIdsRef = useRef<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const handleChatMessage = (
|
||||
chatMessage: ChatMessage,
|
||||
participant?: Participant | undefined
|
||||
) => {
|
||||
if (!participant || participant.isLocal) return
|
||||
const id = chatMessage.id
|
||||
if (id) {
|
||||
const seen = seenChatMsgIdsRef.current
|
||||
if (seen.includes(id)) return
|
||||
seen.push(id)
|
||||
if (seen.length > MAX_TRACKED_CHAT_IDS) seen.shift()
|
||||
}
|
||||
triggerNotificationSound(NotificationType.MessageReceived)
|
||||
toastQueue.add(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocumentPiP } from '../hooks/useDocumentPiP'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import { UNSAFE_PortalProvider as PortalProvider } from '@react-aria/overlays'
|
||||
import { VisualOnlyTooltipsContext } from '@/primitives/VisualOnlyTooltipsContext'
|
||||
|
||||
// Minimal base styles so the PiP window renders correctly on first paint.
|
||||
const ensureBaseStyles = (target: Document) => {
|
||||
if (target.getElementById('pip-base-styles')) return
|
||||
const style = target.createElement('style')
|
||||
style.id = 'pip-base-styles'
|
||||
style.textContent = `
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #0b0f19; }
|
||||
body { overflow: hidden; }
|
||||
* { box-sizing: border-box; }
|
||||
`
|
||||
target.head.appendChild(style)
|
||||
}
|
||||
|
||||
// Clone existing styles to keep the PiP window visually consistent.
|
||||
const copyStyles = (source: Document, target: Document) => {
|
||||
if (target.getElementById('pip-style-clone')) return
|
||||
const marker = target.createElement('meta')
|
||||
marker.id = 'pip-style-clone'
|
||||
target.head.appendChild(marker)
|
||||
|
||||
source.querySelectorAll('style, link[rel="stylesheet"]').forEach((node) => {
|
||||
const cloned = node.cloneNode(true) as HTMLElement
|
||||
target.head.appendChild(cloned)
|
||||
})
|
||||
}
|
||||
|
||||
const syncThemeAttribute = (source: Document, target: Document) => {
|
||||
const theme = source.documentElement.dataset.lkTheme
|
||||
if (theme) {
|
||||
target.documentElement.dataset.lkTheme = theme
|
||||
} else {
|
||||
delete target.documentElement.dataset.lkTheme
|
||||
}
|
||||
}
|
||||
|
||||
const cssVarNameCacheByElement = new WeakMap<HTMLElement, string[]>()
|
||||
const cssVarNameCacheByUri = new Map<string, string[]>()
|
||||
|
||||
const syncCssVariables = (source: Document, target: Document) => {
|
||||
const sourceView = source.defaultView
|
||||
if (!sourceView) return
|
||||
|
||||
const getCachedVarNames = () => {
|
||||
const docEl = source.documentElement
|
||||
if (!docEl) return []
|
||||
|
||||
const cachedByElement = cssVarNameCacheByElement.get(docEl)
|
||||
if (cachedByElement) return cachedByElement
|
||||
|
||||
const cachedByUri = source.baseURI
|
||||
? cssVarNameCacheByUri.get(source.baseURI)
|
||||
: undefined
|
||||
if (cachedByUri) return cachedByUri
|
||||
|
||||
const varNames = new Set<string>()
|
||||
const collectVarsFrom = (element: HTMLElement | null) => {
|
||||
if (!element) return
|
||||
const styles = sourceView.getComputedStyle(element)
|
||||
for (const property of Array.from(styles)) {
|
||||
if (property.startsWith('--')) {
|
||||
varNames.add(property)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectVarsFrom(source.documentElement)
|
||||
collectVarsFrom(source.body)
|
||||
|
||||
const result = Array.from(varNames)
|
||||
cssVarNameCacheByElement.set(docEl, result)
|
||||
if (source.baseURI) {
|
||||
cssVarNameCacheByUri.set(source.baseURI, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const varNames = getCachedVarNames()
|
||||
if (!varNames.length) return
|
||||
|
||||
const rootStyles = sourceView.getComputedStyle(source.documentElement)
|
||||
const bodyStyles = source.body
|
||||
? sourceView.getComputedStyle(source.body)
|
||||
: null
|
||||
|
||||
varNames.forEach((property) => {
|
||||
const bodyValue = bodyStyles?.getPropertyValue(property)
|
||||
const value = bodyValue || rootStyles.getPropertyValue(property)
|
||||
if (value) {
|
||||
target.documentElement.style.setProperty(property, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* React portal into a Document Picture-in-Picture window. Handles window
|
||||
* lifecycle, style/theme sync and routes React Aria overlays via
|
||||
* `UNSAFE_PortalProvider` so they render inside the PiP document.
|
||||
*/
|
||||
export const DocumentPiPPortal = ({
|
||||
isOpen,
|
||||
width,
|
||||
height,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
width?: number
|
||||
height?: number
|
||||
children: React.ReactNode
|
||||
onClose?: () => void
|
||||
}): ReactNode => {
|
||||
const { openPiP, closePiP, pipWindow, isSupported } = useDocumentPiP({
|
||||
width,
|
||||
height,
|
||||
})
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture',
|
||||
})
|
||||
const announce = useScreenReaderAnnounce()
|
||||
const [container, setContainer] = useState<HTMLElement | null>(null)
|
||||
const containerRef = useRef<HTMLElement | null>(null)
|
||||
const prevOpenRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
closePiP()
|
||||
setContainer(null)
|
||||
containerRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSupported) return
|
||||
|
||||
let cancelled = false
|
||||
openPiP().then((win) => {
|
||||
if (!win || cancelled) return
|
||||
const doc = win.document
|
||||
ensureBaseStyles(doc)
|
||||
copyStyles(document, doc)
|
||||
syncThemeAttribute(document, doc)
|
||||
syncCssVariables(document, doc)
|
||||
|
||||
doc.documentElement.setAttribute('lang', document.documentElement.lang)
|
||||
doc.title = t('windowLabel')
|
||||
|
||||
const existingContainer = containerRef.current
|
||||
if (!existingContainer || existingContainer.ownerDocument !== doc) {
|
||||
const nextContainer = doc.createElement('div')
|
||||
nextContainer.id = 'pip-root'
|
||||
nextContainer.style.width = '100%'
|
||||
nextContainer.style.height = '100%'
|
||||
nextContainer.style.display = 'flex'
|
||||
nextContainer.style.alignItems = 'stretch'
|
||||
nextContainer.style.justifyContent = 'center'
|
||||
doc.body.appendChild(nextContainer)
|
||||
containerRef.current = nextContainer
|
||||
setContainer(nextContainer)
|
||||
} else {
|
||||
setContainer(existingContainer)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [closePiP, isOpen, isSupported, openPiP, t])
|
||||
|
||||
// Focus stays on the trigger; PiP is announced as an auxiliary surface.
|
||||
useEffect(() => {
|
||||
const wasOpen = prevOpenRef.current
|
||||
prevOpenRef.current = isOpen
|
||||
|
||||
if (isOpen && !wasOpen) {
|
||||
announce(t('opened'), 'polite')
|
||||
}
|
||||
if (!isOpen && wasOpen) {
|
||||
announce(t('closed'), 'polite')
|
||||
}
|
||||
}, [isOpen, announce, t])
|
||||
|
||||
useRestoreFocus(isOpen, { restoreFocusRaf: true })
|
||||
|
||||
// Escape from either document closes PiP (unless a nested overlay handled it).
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return
|
||||
event.preventDefault()
|
||||
onClose?.()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
pipWindow?.document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
pipWindow?.document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose, pipWindow])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pipWindow) return
|
||||
const handleClose = () => {
|
||||
containerRef.current = null
|
||||
setContainer(null)
|
||||
onClose?.()
|
||||
}
|
||||
pipWindow.addEventListener('pagehide', handleClose)
|
||||
pipWindow.addEventListener('beforeunload', handleClose)
|
||||
return () => {
|
||||
pipWindow.removeEventListener('pagehide', handleClose)
|
||||
pipWindow.removeEventListener('beforeunload', handleClose)
|
||||
}
|
||||
}, [onClose, pipWindow])
|
||||
|
||||
const portal = useMemo(() => {
|
||||
if (!container) return null
|
||||
return createPortal(
|
||||
<PortalProvider getContainer={() => container}>
|
||||
<VisualOnlyTooltipsContext.Provider value={true}>
|
||||
{children}
|
||||
</VisualOnlyTooltipsContext.Provider>
|
||||
</PortalProvider>,
|
||||
container
|
||||
)
|
||||
}, [children, container])
|
||||
|
||||
return portal as unknown as ReactNode
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { useRef, useMemo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { findFirstFocusable } from '@/utils/dom'
|
||||
import { AudioDevicesControl } from '@/features/rooms/livekit/components/controls/Device/AudioDevicesControl'
|
||||
import { VideoDeviceControl } from '@/features/rooms/livekit/components/controls/Device/VideoDeviceControl'
|
||||
import { ScreenShareToggle } from '@/features/rooms/livekit/components/controls/ScreenShareToggle'
|
||||
import { LeaveButton } from '@/features/rooms/livekit/components/controls/LeaveButton'
|
||||
import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
|
||||
import { StartMediaButton } from '@/features/rooms/livekit/components/controls/StartMediaButton'
|
||||
import { usePipElementSize } from '../hooks/usePipElementSize'
|
||||
import { PipOptionsMenu } from './controls/PipOptionsMenu'
|
||||
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
|
||||
|
||||
export const CollapsibleControls = {
|
||||
HAND: 'hand',
|
||||
SCREEN_SHARE: 'screenShare',
|
||||
REACTIONS: 'reactions',
|
||||
} as const
|
||||
|
||||
export type CollapsibleControl =
|
||||
(typeof CollapsibleControls)[keyof typeof CollapsibleControls]
|
||||
|
||||
const COLLAPSE_ORDER: CollapsibleControl[] = [
|
||||
CollapsibleControls.HAND,
|
||||
CollapsibleControls.SCREEN_SHARE,
|
||||
CollapsibleControls.REACTIONS,
|
||||
]
|
||||
|
||||
const BUTTON_SLOT = 50
|
||||
const ESSENTIAL_WIDTH = 260
|
||||
|
||||
const getHiddenControls = (
|
||||
containerWidth: number,
|
||||
showScreenShare: boolean
|
||||
): Set<CollapsibleControl> => {
|
||||
const hidden = new Set<CollapsibleControl>()
|
||||
if (containerWidth <= 0) return hidden
|
||||
|
||||
const collapsible = showScreenShare
|
||||
? COLLAPSE_ORDER
|
||||
: COLLAPSE_ORDER.filter((c) => c !== CollapsibleControls.SCREEN_SHARE)
|
||||
|
||||
const available = containerWidth - ESSENTIAL_WIDTH
|
||||
const maxVisible = Math.max(0, Math.floor(available / BUTTON_SLOT))
|
||||
|
||||
for (let i = 0; i < collapsible.length - maxVisible; i++) {
|
||||
hidden.add(collapsible[i])
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
|
||||
export const PipControlBar = ({
|
||||
showScreenShare,
|
||||
}: {
|
||||
showScreenShare: boolean
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { width } = usePipElementSize(containerRef)
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture',
|
||||
})
|
||||
|
||||
const hidden = useMemo(
|
||||
() => getHiddenControls(width, showScreenShare),
|
||||
[width, showScreenShare]
|
||||
)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'focus-toolbar',
|
||||
handler: useCallback(() => {
|
||||
const doc = containerRef.current?.ownerDocument ?? document
|
||||
findFirstFocusable(doc.getElementById('pip-control-bar'))?.focus()
|
||||
}, []),
|
||||
})
|
||||
|
||||
return (
|
||||
<PipControls
|
||||
ref={containerRef}
|
||||
id="pip-control-bar"
|
||||
role="toolbar"
|
||||
aria-label={t('controlBar')}
|
||||
>
|
||||
<PipControlsCenter>
|
||||
<AudioDevicesControl hideMenu />
|
||||
<VideoDeviceControl hideMenu />
|
||||
{!hidden.has(CollapsibleControls.REACTIONS) && (
|
||||
<ReactionsToggle id="pip-reactions-toggle" />
|
||||
)}
|
||||
{showScreenShare && !hidden.has(CollapsibleControls.SCREEN_SHARE) && (
|
||||
<ScreenShareToggle />
|
||||
)}
|
||||
{!hidden.has(CollapsibleControls.HAND) && <HandToggle />}
|
||||
<PipOptionsMenu overflowControls={hidden} />
|
||||
<LeaveButton />
|
||||
<StartMediaButton />
|
||||
</PipControlsCenter>
|
||||
</PipControls>
|
||||
)
|
||||
}
|
||||
|
||||
const PipControls = styled('div', {
|
||||
base: {
|
||||
flex: '0 0 auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
backgroundColor: 'primaryDark.50',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
},
|
||||
})
|
||||
|
||||
const PipControlsCenter = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
flex: '1 1 auto',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { useRoomPiP } from '../hooks/useRoomPiP'
|
||||
|
||||
export const PipPlaceholder = () => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture.placeholder',
|
||||
})
|
||||
const { close } = useRoomPiP()
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Illustration
|
||||
src="/assets/pip.svg"
|
||||
alt=""
|
||||
width={102}
|
||||
height={72}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Title>{t('title')}</Title>
|
||||
<Description>{t('description')}</Description>
|
||||
<BringBackLink onClick={close}>{t('bringBack')}</BringBackLink>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
gap: '0.5rem',
|
||||
padding: '1.5rem',
|
||||
textAlign: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
const Illustration = styled('img', {
|
||||
base: {
|
||||
marginBottom: '0.25rem',
|
||||
},
|
||||
})
|
||||
|
||||
const Title = styled('p', {
|
||||
base: {
|
||||
color: 'white',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
margin: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const Description = styled('p', {
|
||||
base: {
|
||||
color: '#DFE2EA',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.4,
|
||||
margin: 0,
|
||||
maxWidth: '312px',
|
||||
},
|
||||
})
|
||||
|
||||
const BringBackLink = styled('button', {
|
||||
base: {
|
||||
color: '#A2B6FF',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.4,
|
||||
cursor: 'pointer',
|
||||
marginTop: '0.25rem',
|
||||
borderRadius: '2px',
|
||||
_hover: {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
_focusVisible: {
|
||||
outline: '2px solid #A2B6FF',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { reactionsStore } from '@/stores/reactions'
|
||||
import { FloatingReaction } from '@/features/reactions/components/ReactionPortals'
|
||||
import type { Reaction } from '@/features/reactions/types'
|
||||
|
||||
/**
|
||||
* Renders floating emoji reactions inside the PiP window.
|
||||
* Reads the same shared reactionsStore used by the main window.
|
||||
*/
|
||||
export const PipReactionPortals = () => {
|
||||
const { reactions } = useSnapshot(reactionsStore)
|
||||
|
||||
return (
|
||||
<>
|
||||
{reactions.map((reaction) => (
|
||||
<PipFloatingReaction key={reaction.id} reaction={reaction} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PipFloatingReaction = ({ reaction }: { reaction: Reaction }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
|
||||
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<FloatingReaction
|
||||
emoji={reaction.emoji}
|
||||
speed={speed}
|
||||
scale={scale}
|
||||
name={reaction.participantName}
|
||||
isLocal={reaction.isLocal}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { supportsScreenSharing } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { SidePanel } from '@/features/rooms/livekit/components/SidePanel'
|
||||
import {
|
||||
SidePanelStoreProvider,
|
||||
useSidePanel,
|
||||
} from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { ReactionsToolbarStoreProvider } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||
import { pipLayoutStore } from '../stores/pipLayoutStore'
|
||||
import { useEscapeDismiss } from '../hooks/useEscapeDismiss'
|
||||
import { usePipKeyboardShortcuts } from '../hooks/usePipKeyboardShortcuts'
|
||||
import { usePipRestoreFocus } from '../hooks/usePipRestoreFocus'
|
||||
import { usePipFocusModality } from '../hooks/usePipFocusModality'
|
||||
import { PipControlBar } from './PipControlBar'
|
||||
import { ReactionsToolbar } from '@/features/reactions/components/toolbar/ReactionsToolbar'
|
||||
import { PipStage } from './layouts/PipStage'
|
||||
import { PipNotificationOverlay } from './notifications/PipNotificationOverlay'
|
||||
import { PipConnectionStateToast } from './notifications/PipConnectionStateToast'
|
||||
import { PipReactionPortals } from './PipReactionPortals'
|
||||
|
||||
export const PipView = () => {
|
||||
return (
|
||||
<SidePanelStoreProvider value={pipLayoutStore}>
|
||||
<ReactionsToolbarStoreProvider value={pipLayoutStore}>
|
||||
<PipViewContent />
|
||||
</ReactionsToolbarStoreProvider>
|
||||
</SidePanelStoreProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const PipViewContent = () => {
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture',
|
||||
})
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { isSidePanelOpen, closePanel } = useSidePanel()
|
||||
|
||||
// Escape closes the side panel instead of the whole PiP window.
|
||||
useEscapeDismiss(containerRef, isSidePanelOpen, closePanel)
|
||||
|
||||
// Forward keyboard shortcuts (Ctrl+D, Ctrl+E, etc.) to the main store.
|
||||
usePipKeyboardShortcuts(containerRef)
|
||||
|
||||
// Sync React Aria's focus-visible modality with the PiP document.
|
||||
usePipFocusModality(containerRef)
|
||||
|
||||
// Side panels open via a menu item that unmounts on click; fall back to the
|
||||
// options button so focus returns somewhere visible.
|
||||
const resolveTrigger = useCallback((activeEl: HTMLElement | null) => {
|
||||
if (activeEl?.tagName === 'DIV') {
|
||||
const doc = containerRef.current?.ownerDocument ?? document
|
||||
return doc.getElementById('room-options-trigger')
|
||||
}
|
||||
return activeEl
|
||||
}, [])
|
||||
usePipRestoreFocus(containerRef, isSidePanelOpen, { resolveTrigger })
|
||||
|
||||
return (
|
||||
<PipContainer
|
||||
ref={containerRef}
|
||||
role="region"
|
||||
aria-label={t('windowLabel')}
|
||||
>
|
||||
<PipStage />
|
||||
<ReactionsToolbar
|
||||
toggleId="pip-reactions-toggle"
|
||||
controlBarId="pip-control-bar"
|
||||
/>
|
||||
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
|
||||
<SidePanel />
|
||||
<PipReactionPortals />
|
||||
<OverlayStack>
|
||||
<PipConnectionStateToast />
|
||||
<PipNotificationOverlay />
|
||||
</OverlayStack>
|
||||
</PipContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const OverlayStack = styled('div', {
|
||||
base: {
|
||||
position: 'absolute',
|
||||
top: '0.5rem',
|
||||
left: '0.5rem',
|
||||
right: '0.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.375rem',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1000,
|
||||
'& > *': { pointerEvents: 'auto' },
|
||||
},
|
||||
})
|
||||
|
||||
const PipContainer = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'minmax(0, 1fr) auto auto',
|
||||
backgroundColor: 'primaryDark.50',
|
||||
// Disable LiveKit's own border-radius on tiles so our containers
|
||||
// (GridCell, Thumbnail, StageFrame) own the clipping exclusively.
|
||||
'--lk-border-radius': '4px',
|
||||
'& .lk-participant-tile': {
|
||||
height: '100%',
|
||||
},
|
||||
'& .lk-participant-media': {
|
||||
height: '100%',
|
||||
},
|
||||
'& .lk-participant-media-video': {
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
},
|
||||
'& .lk-grid-layout': {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
|
||||
import { roomPiPStore } from '@/stores/roomPiP'
|
||||
import { DocumentPiPPortal } from './DocumentPiPPortal'
|
||||
import { PipView } from './PipView'
|
||||
import { useRoomPiP } from '../hooks/useRoomPiP'
|
||||
|
||||
/**
|
||||
* Wrapper that mounts the PiP UI when room-level PiP state is enabled.
|
||||
* Bridges Valtio-backed PiP state with DocumentPiPPortal and PipView rendering.
|
||||
* PiP panel state is decoupled via explicit pipLayoutStore injection.
|
||||
*/
|
||||
export const RoomPiP = (): ReactNode => {
|
||||
const { isOpen, close } = useRoomPiP()
|
||||
|
||||
// Reset PiP state on unmount (e.g. leaving the room) so the next session
|
||||
// starts with PiP closed and doesn't try to auto-reopen without a user gesture.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
roomPiPStore.isOpen = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const portal = DocumentPiPPortal({
|
||||
isOpen,
|
||||
onClose: close,
|
||||
children: <PipView />,
|
||||
})
|
||||
return portal
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import { FocusScope } from '@react-aria/focus'
|
||||
import { Box, Button } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PipOptionsMenuItems } from './PipOptionsMenuItems'
|
||||
import { useEscapeDismiss } from '@/features/pip/hooks/useEscapeDismiss'
|
||||
import type { CollapsibleControl } from '../PipControlBar'
|
||||
|
||||
type PipOptionsMenuProps = {
|
||||
overflowControls?: Set<CollapsibleControl>
|
||||
}
|
||||
|
||||
/**
|
||||
* PiP-native options menu. The shared `Menu` primitive mis-positions its
|
||||
* popover and loses focus across documents, so we drive open/close, focus
|
||||
* and dismissal ourselves.
|
||||
*/
|
||||
export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const label = t('options.buttonLabel')
|
||||
|
||||
useEscapeDismiss(wrapperRef, isOpen, () => {
|
||||
setIsOpen(false)
|
||||
requestAnimationFrame(() => triggerRef.current?.focus())
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const doc = wrapperRef.current?.ownerDocument ?? document
|
||||
|
||||
const handleMenuItemClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper || !target) return
|
||||
if (wrapper.querySelector('button')?.contains(target)) return
|
||||
if (target.closest('[role="menuitem"]')) {
|
||||
requestAnimationFrame(() => {
|
||||
setIsOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleOutsideClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper || !target) return
|
||||
if (wrapper.contains(target)) return
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
doc.addEventListener('click', handleMenuItemClick, true)
|
||||
doc.addEventListener('mousedown', handleOutsideClick, true)
|
||||
return () => {
|
||||
doc.removeEventListener('click', handleMenuItemClick, true)
|
||||
doc.removeEventListener('mousedown', handleOutsideClick, true)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
id="room-options-trigger"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
tooltip={label}
|
||||
onPress={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<RiMoreFill />
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
bottom: 'calc(100% + 0.85rem)',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 10,
|
||||
})}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<FocusScope autoFocus>
|
||||
<Box size="sm" type="popover" variant="dark">
|
||||
<PipOptionsMenuItems overflowControls={overflowControls} />
|
||||
</Box>
|
||||
</FocusScope>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Menu as RACMenu, MenuSection } from 'react-aria-components'
|
||||
import { Separator } from '@/primitives/Separator'
|
||||
import { FeedbackMenuItem } from '@/features/rooms/livekit/components/controls/Options/FeedbackMenuItem'
|
||||
import { EffectsMenuItem } from '@/features/rooms/livekit/components/controls/Options/EffectsMenuItem'
|
||||
import { SupportMenuItem } from '@/features/rooms/livekit/components/controls/Options/SupportMenuItem'
|
||||
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
|
||||
import { PipOverflowItems } from './PipOverflowItems'
|
||||
import type { CollapsibleControl } from '../PipControlBar'
|
||||
|
||||
type PipOptionsMenuItemsProps = {
|
||||
overflowControls?: Set<CollapsibleControl>
|
||||
}
|
||||
|
||||
export const PipOptionsMenuItems = ({
|
||||
overflowControls,
|
||||
}: PipOptionsMenuItemsProps) => {
|
||||
const hasOverflow = (overflowControls?.size ?? 0) > 0
|
||||
|
||||
const hasOverflowControls = hasOverflow && overflowControls
|
||||
|
||||
return (
|
||||
<RACMenu
|
||||
style={{
|
||||
minWidth: '150px',
|
||||
width: '300px',
|
||||
}}
|
||||
>
|
||||
{hasOverflowControls && (
|
||||
<>
|
||||
<MenuSection>
|
||||
<PipOverflowItems overflowControls={overflowControls} />
|
||||
</MenuSection>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<MenuSection>
|
||||
<PictureInPictureMenuItem />
|
||||
<EffectsMenuItem />
|
||||
</MenuSection>
|
||||
<Separator />
|
||||
<MenuSection>
|
||||
<SupportMenuItem />
|
||||
<FeedbackMenuItem />
|
||||
</MenuSection>
|
||||
</RACMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { RiHand, RiArrowUpLine, RiEmotionLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Track } from 'livekit-client'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useRoomContext, useTrackToggle } from '@livekit/components-react'
|
||||
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||
import { CollapsibleControls, type CollapsibleControl } from '../PipControlBar'
|
||||
|
||||
type PipOverflowItemsProps = {
|
||||
overflowControls: Set<CollapsibleControl>
|
||||
}
|
||||
|
||||
export const PipOverflowItems = ({
|
||||
overflowControls,
|
||||
}: PipOverflowItemsProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const room = useRoomContext()
|
||||
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
|
||||
participant: room.localParticipant,
|
||||
})
|
||||
const { buttonProps: screenShareProps, enabled: isScreenSharing } =
|
||||
useTrackToggle({
|
||||
source: Track.Source.ScreenShare,
|
||||
captureOptions: { audio: true, selfBrowserSurface: 'include' },
|
||||
})
|
||||
const { toggle: toggleReactions } = useReactionsToolbar()
|
||||
const itemClass = menuRecipe({ icon: true, variant: 'dark' }).item
|
||||
|
||||
return (
|
||||
<>
|
||||
{overflowControls.has(CollapsibleControls.REACTIONS) && (
|
||||
<MenuItem onAction={toggleReactions} className={itemClass}>
|
||||
<RiEmotionLine size={20} />
|
||||
{t('controls.reactions.button')}
|
||||
</MenuItem>
|
||||
)}
|
||||
{overflowControls.has(CollapsibleControls.SCREEN_SHARE) && (
|
||||
<MenuItem
|
||||
onAction={() =>
|
||||
screenShareProps.onClick?.(
|
||||
{} as React.MouseEvent<HTMLButtonElement>
|
||||
)
|
||||
}
|
||||
className={itemClass}
|
||||
>
|
||||
<RiArrowUpLine size={20} />
|
||||
{t(
|
||||
isScreenSharing
|
||||
? 'controls.screenShare.stop'
|
||||
: 'controls.screenShare.start'
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{overflowControls.has(CollapsibleControls.HAND) && (
|
||||
<MenuItem onAction={toggleRaisedHand} className={itemClass}>
|
||||
<RiHand size={20} />
|
||||
{isHandRaised ? t('controls.hand.lower') : t('controls.hand.raise')}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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(
|
||||
({ mainTrack, thumbnailTrack }: PipFocusLayoutProps) => {
|
||||
return (
|
||||
<FocusContainer>
|
||||
<MainSlot>
|
||||
<ParticipantTile key={getTrackKey(mainTrack)} trackRef={mainTrack} />
|
||||
</MainSlot>
|
||||
{thumbnailTrack && (
|
||||
<Thumbnail>
|
||||
<ParticipantTile
|
||||
key={getTrackKey(thumbnailTrack)}
|
||||
trackRef={thumbnailTrack}
|
||||
/>
|
||||
</Thumbnail>
|
||||
)}
|
||||
</FocusContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
PipFocusLayout.displayName = 'PipFocusLayout'
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
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(({ 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} />
|
||||
</GridCell>
|
||||
))}
|
||||
</GridContainer>
|
||||
)
|
||||
})
|
||||
PipGridLayout.displayName = 'PipGridLayout'
|
||||
|
||||
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%',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
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,
|
||||
marginLeft: '0.5rem',
|
||||
marginRight: '0.5rem',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
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 connectionLabels: Partial<Record<ConnectionState, string>> = {
|
||||
[ConnectionState.Reconnecting]: t('reconnecting'),
|
||||
[ConnectionState.Disconnected]: t('disconnected'),
|
||||
}
|
||||
const label = connectionLabels[state] ?? null
|
||||
|
||||
if (!label) return null
|
||||
|
||||
return <Banner role="status">{label}</Banner>
|
||||
}
|
||||
|
||||
const Banner = styled('div', {
|
||||
base: {
|
||||
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',
|
||||
animation: 'fade 200ms',
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
animation: 'none',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
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 { StyledToastContainer } from '@/features/notifications/components/Toast'
|
||||
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.
|
||||
*/
|
||||
const MAX_VISIBLE = 3
|
||||
|
||||
export const PipNotificationOverlay = () => {
|
||||
const state = useToastQueue<ToastData>(toastQueue)
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture',
|
||||
})
|
||||
|
||||
if (state.visibleToasts.length === 0) return null
|
||||
|
||||
const toasts = state.visibleToasts.slice(0, MAX_VISIBLE)
|
||||
|
||||
return (
|
||||
<Region
|
||||
role="region"
|
||||
aria-label={t('notificationsLabel')}
|
||||
aria-live="polite"
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<StyledToastContainer
|
||||
key={toast.key}
|
||||
aria-atomic="true"
|
||||
css={{
|
||||
margin: 0,
|
||||
marginLeft: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingRight: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</StyledToastContainer>
|
||||
))}
|
||||
</Region>
|
||||
)
|
||||
}
|
||||
|
||||
const Region = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.375rem',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
})
|
||||
@@ -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 })
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
type DocumentPictureInPicture = {
|
||||
requestWindow: (options?: {
|
||||
width?: number
|
||||
height?: number
|
||||
}) => Promise<Window>
|
||||
}
|
||||
|
||||
type WindowWithDocumentPiP = Window & {
|
||||
documentPictureInPicture?: DocumentPictureInPicture
|
||||
}
|
||||
|
||||
export const useDocumentPiP = ({
|
||||
width = 400,
|
||||
height = 480,
|
||||
}: {
|
||||
width?: number
|
||||
height?: number
|
||||
} = {}) => {
|
||||
const [pipWindow, setPipWindow] = useState<Window | null>(null)
|
||||
const pipWindowRef = useRef<Window | null>(null)
|
||||
const pendingPiPRef = useRef<Promise<Window | null> | null>(null)
|
||||
|
||||
const [isSupported] = useState(() => {
|
||||
if (typeof globalThis === 'undefined') return false
|
||||
return 'documentPictureInPicture' in globalThis
|
||||
})
|
||||
|
||||
const openPiP = useCallback(async () => {
|
||||
if (!isSupported) return null
|
||||
const existingWindow = pipWindowRef.current
|
||||
if (existingWindow && !existingWindow.closed) return existingWindow
|
||||
|
||||
if (pendingPiPRef.current) return pendingPiPRef.current
|
||||
|
||||
// Request a new PiP window from the browser API.
|
||||
const pip = (globalThis as unknown as WindowWithDocumentPiP)
|
||||
.documentPictureInPicture
|
||||
if (!pip) return null
|
||||
|
||||
const requestPromise = (async () => {
|
||||
try {
|
||||
const win = await pip.requestWindow({ width, height })
|
||||
const currentWindow = pipWindowRef.current
|
||||
if (currentWindow && !currentWindow.closed) return currentWindow
|
||||
setPipWindow(win)
|
||||
return win
|
||||
} catch (error) {
|
||||
// Avoid unhandled rejections if the user blocks or closes the request.
|
||||
console.error('Failed to open Picture-in-Picture window', error)
|
||||
return null
|
||||
} finally {
|
||||
pendingPiPRef.current = null
|
||||
}
|
||||
})()
|
||||
|
||||
pendingPiPRef.current = requestPromise
|
||||
return requestPromise
|
||||
}, [height, isSupported, width])
|
||||
|
||||
const closePiP = useCallback(() => {
|
||||
if (!pipWindow) return
|
||||
if (!pipWindow.closed) {
|
||||
pipWindow.close()
|
||||
}
|
||||
setPipWindow(null)
|
||||
}, [pipWindow])
|
||||
|
||||
useEffect(() => {
|
||||
pipWindowRef.current = pipWindow
|
||||
}, [pipWindow])
|
||||
|
||||
// Force-close the native PiP window when the hook unmounts (e.g. the user
|
||||
// hangs up and the room is navigated away before `closePiP` could run).
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const win = pipWindowRef.current
|
||||
if (win && !win.closed) win.close()
|
||||
pipWindowRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pipWindow) return
|
||||
|
||||
const handleClose = () => {
|
||||
setPipWindow(null)
|
||||
}
|
||||
|
||||
pipWindow.addEventListener('pagehide', handleClose)
|
||||
pipWindow.addEventListener('beforeunload', handleClose)
|
||||
|
||||
return () => {
|
||||
pipWindow.removeEventListener('pagehide', handleClose)
|
||||
pipWindow.removeEventListener('beforeunload', handleClose)
|
||||
}
|
||||
}, [pipWindow])
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isOpen: !!pipWindow && !pipWindow.closed,
|
||||
pipWindow,
|
||||
openPiP,
|
||||
closePiP,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
export const useEscapeDismiss = (
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
isActive: boolean,
|
||||
onDismiss: () => void
|
||||
) => {
|
||||
const latestOnDismiss = useRef(onDismiss)
|
||||
useEffect(() => {
|
||||
latestOnDismiss.current = onDismiss
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
latestOnDismiss.current()
|
||||
}
|
||||
|
||||
el.addEventListener('keydown', handler)
|
||||
return () => el.removeEventListener('keydown', handler)
|
||||
}, [ref, isActive])
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useEffect, useState, type RefObject } from 'react'
|
||||
|
||||
type Size = { width: number; height: number }
|
||||
|
||||
/**
|
||||
* Observes an element's size, even when mounted in the PiP document.
|
||||
* Resolves `ResizeObserver` from the element's own window.
|
||||
*/
|
||||
export const usePipElementSize = <T extends HTMLElement>(
|
||||
ref: RefObject<T | null>
|
||||
): Size => {
|
||||
const [size, setSize] = useState<Size>({ width: 0, height: 0 })
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
setSize({ width: rect.width, height: rect.height })
|
||||
}, [ref])
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
|
||||
measure()
|
||||
|
||||
const RO =
|
||||
el.ownerDocument.defaultView?.ResizeObserver ?? globalThis.ResizeObserver
|
||||
if (!RO) return
|
||||
|
||||
const observer = new RO((entries) => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
const { width, height } = entry.contentRect
|
||||
setSize({ width, height })
|
||||
})
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [ref, measure])
|
||||
|
||||
return size
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useLayoutEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
type Options = {
|
||||
/** Animation duration in ms. */
|
||||
duration?: number
|
||||
/** CSS easing function. */
|
||||
easing?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* FLIP (First, Last, Invert, Play) animation hook.
|
||||
*
|
||||
* For every keyed direct child of `containerRef`, records its position
|
||||
* before a render (the "first" rect) and, once the DOM has committed, plays
|
||||
* an inverse transform back to the identity position. The effect is a
|
||||
* smooth slide whenever tiles are added, removed, reordered, or a new grid
|
||||
* shape shifts them.
|
||||
*
|
||||
* Safe to call inside a Document PiP window: uses the element's own
|
||||
* Web Animations API (element.animate) which lives in the PiP document.
|
||||
* Respects `prefers-reduced-motion` and no-ops on the first mount.
|
||||
*/
|
||||
export const usePipFlipAnimations = <T extends HTMLElement>(
|
||||
containerRef: RefObject<T | null>,
|
||||
keys: ReadonlyArray<string>,
|
||||
{ duration = 220, easing = 'cubic-bezier(0.2, 0, 0, 1)' }: Options = {}
|
||||
) => {
|
||||
const prevRectsRef = useRef<Map<string, DOMRect>>(new Map())
|
||||
const firstRunRef = useRef(true)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const doc = container.ownerDocument
|
||||
const view = doc.defaultView
|
||||
const reduceMotion = view?.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)'
|
||||
).matches
|
||||
|
||||
const children = Array.from(container.children) as HTMLElement[]
|
||||
const nextRects = new Map<string, DOMRect>()
|
||||
children.forEach((el, i) => {
|
||||
const key = keys[i]
|
||||
if (!key) return
|
||||
nextRects.set(key, el.getBoundingClientRect())
|
||||
})
|
||||
|
||||
if (firstRunRef.current) {
|
||||
firstRunRef.current = false
|
||||
prevRectsRef.current = nextRects
|
||||
return
|
||||
}
|
||||
|
||||
if (!reduceMotion) {
|
||||
children.forEach((el, i) => {
|
||||
const key = keys[i]
|
||||
if (!key) return
|
||||
const prev = prevRectsRef.current.get(key)
|
||||
const next = nextRects.get(key)
|
||||
if (!prev || !next) return
|
||||
|
||||
const dx = prev.left - next.left
|
||||
const dy = prev.top - next.top
|
||||
const sx = next.width === 0 ? 1 : prev.width / next.width
|
||||
const sy = next.height === 0 ? 1 : prev.height / next.height
|
||||
|
||||
// Skip no-ops: sub-pixel shifts don't benefit from animation.
|
||||
if (
|
||||
Math.abs(dx) < 1 &&
|
||||
Math.abs(dy) < 1 &&
|
||||
Math.abs(sx - 1) < 0.01 &&
|
||||
Math.abs(sy - 1) < 0.01
|
||||
)
|
||||
return
|
||||
|
||||
el.animate(
|
||||
[
|
||||
{
|
||||
transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`,
|
||||
},
|
||||
{ transform: 'translate(0, 0) scale(1, 1)' },
|
||||
],
|
||||
{ duration, easing, fill: 'backwards' }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
prevRectsRef.current = nextRects
|
||||
}, [containerRef, duration, easing, keys])
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
import { setInteractionModality } from '@react-aria/interactions'
|
||||
|
||||
/**
|
||||
* Sync React Aria's interaction modality with the PiP document.
|
||||
* React Aria only installs keyboard/mouse listeners on the main document,
|
||||
* so focus-visible rings never appear in PiP without this bridge.
|
||||
*/
|
||||
export const usePipFocusModality = (
|
||||
containerRef: RefObject<HTMLElement | null>
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const doc = containerRef.current?.ownerDocument
|
||||
if (!doc || doc === document) return
|
||||
|
||||
const onKeyDown = () => setInteractionModality('keyboard')
|
||||
const onMouseDown = () => setInteractionModality('pointer')
|
||||
|
||||
doc.addEventListener('keydown', onKeyDown, true)
|
||||
doc.addEventListener('mousedown', onMouseDown, true)
|
||||
return () => {
|
||||
doc.removeEventListener('keydown', onKeyDown, true)
|
||||
doc.removeEventListener('mousedown', onMouseDown, true)
|
||||
}
|
||||
}, [containerRef])
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
|
||||
import { formatShortcutKey } from '@/features/shortcuts/utils'
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
|
||||
/**
|
||||
* Mirror the main-window keyboard shortcuts inside the PiP document.
|
||||
*
|
||||
* The central `useKeyboardShortcuts` hook listens on `window`, which is the
|
||||
* main document's window. Keydown events from the PiP document never reach
|
||||
* it. This hook attaches the same dispatch logic to the PiP document so that
|
||||
* Ctrl+D (mic), Ctrl+E (cam), etc. work identically in both contexts.
|
||||
*/
|
||||
export const usePipKeyboardShortcuts = (
|
||||
containerRef: RefObject<HTMLElement | null>
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const doc = containerRef.current?.ownerDocument
|
||||
if (!doc || doc === document) return
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const { key, metaKey, ctrlKey, shiftKey, altKey } = e
|
||||
if (!key) return
|
||||
|
||||
const shortcutKey = formatShortcutKey({
|
||||
key,
|
||||
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
|
||||
shiftKey,
|
||||
altKey,
|
||||
})
|
||||
|
||||
let handler = keyboardShortcutsStore.shortcuts.get(shortcutKey)
|
||||
if (!handler && shortcutKey === 'ctrl+shift+?') {
|
||||
handler = keyboardShortcutsStore.shortcuts.get('ctrl+shift+/')
|
||||
}
|
||||
if (!handler) return
|
||||
|
||||
e.preventDefault()
|
||||
handler()
|
||||
}
|
||||
|
||||
doc.addEventListener('keydown', onKeyDown)
|
||||
return () => doc.removeEventListener('keydown', onKeyDown)
|
||||
}, [containerRef])
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
type Options = {
|
||||
/** Remap the captured trigger (e.g. when it unmounts on click). */
|
||||
resolveTrigger?: (activeEl: HTMLElement | null) => HTMLElement | null
|
||||
}
|
||||
|
||||
/**
|
||||
* `useRestoreFocus`: captures and restores focus via the PiP
|
||||
* document instead of the main one.
|
||||
*/
|
||||
export const usePipRestoreFocus = (
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
isOpen: boolean,
|
||||
{ resolveTrigger }: Options = {}
|
||||
) => {
|
||||
const prevOpenRef = useRef(false)
|
||||
const triggerRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const doc = ref.current?.ownerDocument
|
||||
const wasOpen = prevOpenRef.current
|
||||
prevOpenRef.current = isOpen
|
||||
|
||||
if (!doc) return
|
||||
|
||||
if (!wasOpen && isOpen) {
|
||||
const activeEl = doc.activeElement as HTMLElement | null
|
||||
triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl
|
||||
return
|
||||
}
|
||||
|
||||
if (wasOpen && !isOpen) {
|
||||
const trigger = triggerRef.current
|
||||
triggerRef.current = null
|
||||
if (trigger && doc.contains(trigger)) {
|
||||
requestAnimationFrame(() => trigger.focus({ preventScroll: true }))
|
||||
}
|
||||
}
|
||||
}, [ref, isOpen, resolveTrigger])
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { roomPiPStore } from '@/stores/roomPiP'
|
||||
|
||||
export const useRoomPiP = () => {
|
||||
const { isOpen } = useSnapshot(roomPiPStore)
|
||||
const isSupported =
|
||||
typeof globalThis !== 'undefined' &&
|
||||
'documentPictureInPicture' in globalThis
|
||||
|
||||
const open = useCallback(() => {
|
||||
roomPiPStore.isOpen = true
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => {
|
||||
roomPiPStore.isOpen = false
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
roomPiPStore.isOpen = !roomPiPStore.isOpen
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { proxy } from 'valtio'
|
||||
import type { PanelId, SubPanelId } from '@/features/rooms/livekit/types/panel'
|
||||
|
||||
type PipLayoutState = {
|
||||
activePanelId: PanelId | null
|
||||
activeSubPanelId: SubPanelId | null
|
||||
showReactionsToolbar: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Separate layout store for the PiP window.
|
||||
* Decouples PiP side panel state from the main view so opening Chat/Info/etc.
|
||||
* in PiP does not affect the main window and vice versa.
|
||||
*/
|
||||
export const pipLayoutStore = proxy<PipLayoutState>({
|
||||
activePanelId: null,
|
||||
activeSubPanelId: null,
|
||||
showReactionsToolbar: false,
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
export type PipTilePlacement = {
|
||||
gridColumn: string
|
||||
gridRow: number
|
||||
}
|
||||
|
||||
export type PipGridLayout = {
|
||||
cols: number
|
||||
rows: number
|
||||
/** Number of CSS sub-columns; use as `repeat(subColumns, 1fr)`. */
|
||||
subColumns: number
|
||||
/** One entry per tile, in input order. */
|
||||
placements: PipTilePlacement[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Target tile aspect ratio used to score candidate grid shapes.
|
||||
*
|
||||
* Video sources are 16:9, but picking 16:9 as the target makes the
|
||||
* scorer indifferent between a stretched 2-col slab (aspect ~2.7) and a
|
||||
* squarer 3-col tile (aspect ~1.2) because log distance is symmetric.
|
||||
* The UI works better with square, face-friendly tiles. This target keeps
|
||||
* wide windows from collapsing to 2 columns with short, stretched rows
|
||||
* and pushes the scorer to add a column instead.
|
||||
*/
|
||||
const TARGET_TILE_ASPECT = 1
|
||||
|
||||
/**
|
||||
* Smallest count from which we force at least two columns.
|
||||
* For 1-3 participants it is acceptable to stack vertically in tall
|
||||
* windows, but from 4 people onwards we keep >=2 columns to
|
||||
* avoid endless vertical scrolling; the scorer handles the rest.
|
||||
*/
|
||||
const FORCE_TWO_COLS_COUNT = 4
|
||||
|
||||
const pickGridShape = (
|
||||
count: number,
|
||||
width: number,
|
||||
height: number
|
||||
): { cols: number; rows: number } => {
|
||||
if (count <= 1) return { cols: 1, rows: Math.max(1, count) }
|
||||
if (width <= 0 || height <= 0) return { cols: count, rows: 1 }
|
||||
|
||||
const minCols = count >= FORCE_TWO_COLS_COUNT ? 2 : 1
|
||||
|
||||
let best = {
|
||||
cols: minCols,
|
||||
rows: Math.ceil(count / minCols),
|
||||
score: -Infinity,
|
||||
}
|
||||
for (let cols = minCols; cols <= count; cols++) {
|
||||
const rows = Math.ceil(count / cols)
|
||||
const tileW = width / cols
|
||||
const tileH = height / rows
|
||||
if (tileW <= 0 || tileH <= 0) continue
|
||||
|
||||
// Score: aspect close to target, few empty cells, large tile area,
|
||||
// and a tiny bias toward fewer rows so ties (perfectly square shapes)
|
||||
// resolve in favour of a shorter, wider grid.
|
||||
const aspectScore = -Math.abs(Math.log(tileW / tileH / TARGET_TILE_ASPECT))
|
||||
const emptyCells = cols * rows - count
|
||||
const fillScore = -emptyCells * 0.1
|
||||
const areaScore = Math.log(tileW * tileH) * 0.5
|
||||
const rowsPenalty = -rows * 0.01
|
||||
|
||||
const score = aspectScore * 2 + fillScore + areaScore + rowsPenalty
|
||||
if (score > best.score) best = { cols, rows, score }
|
||||
}
|
||||
return { cols: best.cols, rows: best.rows }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function. Given a tile count and stage dimensions, returns the CSS
|
||||
* grid layout for the PiP stage:
|
||||
*
|
||||
* - picks a cols x rows shape close to 16:9 tiles,
|
||||
* - stretches any partial last row so its tiles share the full row width
|
||||
* (no empty cells, no small centered tile).
|
||||
*
|
||||
* Callers consume the result directly: `subColumns` feeds
|
||||
* `grid-template-columns: repeat(N, 1fr)` and each tile reads its own
|
||||
* `gridColumn`/`gridRow` from `placements`.
|
||||
*/
|
||||
export const computePipGridLayout = (
|
||||
count: number,
|
||||
width: number,
|
||||
height: number
|
||||
): PipGridLayout => {
|
||||
if (count <= 0) {
|
||||
return { cols: 1, rows: 1, subColumns: 1, placements: [] }
|
||||
}
|
||||
|
||||
const { cols, rows } = pickGridShape(count, width, height)
|
||||
const tilesInLastRow = count - cols * (rows - 1)
|
||||
const hasPartialRow = tilesInLastRow > 0 && tilesInLastRow < cols
|
||||
|
||||
const subColumns = hasPartialRow ? cols * tilesInLastRow : cols
|
||||
const fullRowSpan = hasPartialRow ? tilesInLastRow : 1
|
||||
const lastRowSpan = hasPartialRow ? cols : 1
|
||||
|
||||
const placements: PipTilePlacement[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const row = Math.floor(i / cols)
|
||||
const colIndex = i % cols
|
||||
const isLastRow = row === rows - 1 && hasPartialRow
|
||||
const span = isLastRow ? lastRowSpan : fullRowSpan
|
||||
const colStart = colIndex * span + 1
|
||||
placements.push({
|
||||
gridColumn: `${colStart} / span ${span}`,
|
||||
gridRow: row + 1,
|
||||
})
|
||||
}
|
||||
|
||||
return { cols, rows, subColumns, placements }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
isTrackReference,
|
||||
TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
|
||||
/**
|
||||
* Helpers used by the PiP layouts to classify/pick tracks.
|
||||
* Kept free of React so they are trivially testable and cheap to call.
|
||||
*/
|
||||
|
||||
export const pickScreenShareTrack = (
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
): TrackReferenceOrPlaceholder | undefined =>
|
||||
tracks
|
||||
.filter((track) => isTrackReference(track))
|
||||
.find((track) => track.publication.source === Track.Source.ScreenShare)
|
||||
|
||||
export const pickLocalCameraTrack = (
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
): TrackReferenceOrPlaceholder | undefined =>
|
||||
tracks.find(
|
||||
(track) =>
|
||||
track.source === Track.Source.Camera && track.participant?.isLocal
|
||||
)
|
||||
|
||||
export const pickRemoteCameraTrack = (
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
): TrackReferenceOrPlaceholder | undefined =>
|
||||
tracks.find(
|
||||
(track) =>
|
||||
track.source === Track.Source.Camera && !track.participant?.isLocal
|
||||
)
|
||||
|
||||
export const isCameraTrack = (track: TrackReferenceOrPlaceholder): boolean =>
|
||||
track.source === Track.Source.Camera
|
||||
|
||||
/**
|
||||
* Produces a stable React key for a track so resizes/reshuffles of the grid
|
||||
* do not remount the underlying <video> element.
|
||||
*/
|
||||
export const getTrackKey = (track: TrackReferenceOrPlaceholder): string => {
|
||||
const identity = track.participant?.identity ?? 'unknown'
|
||||
if (isTrackReference(track)) {
|
||||
return `${identity}::${track.source}::${track.publication.trackSid}`
|
||||
}
|
||||
return `${identity}::${track.source}::placeholder`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiEmotionLine } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
@@ -6,37 +6,37 @@ import { ToggleButton } from '@/primitives'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { REACTIONS_TOOLBAR_ID } from '../constants'
|
||||
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
|
||||
const focusReactionsToolbar = () => {
|
||||
document
|
||||
.getElementById(REACTIONS_TOOLBAR_ID)
|
||||
?.querySelector<HTMLElement>('button')
|
||||
?.focus()
|
||||
type ReactionsToggleProps = {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export const ReactionsToggle = () => {
|
||||
export const ReactionsToggle = ({
|
||||
id = 'reactions-toggle',
|
||||
}: ReactionsToggleProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
|
||||
const { isOpen, toggle } = useReactionsToolbar()
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const handleShortcut = useCallback(() => {
|
||||
if (layoutStore.showReactionsToolbar) {
|
||||
focusReactionsToolbar()
|
||||
if (isOpen) {
|
||||
const doc = buttonRef.current?.ownerDocument ?? document
|
||||
doc
|
||||
.getElementById(REACTIONS_TOOLBAR_ID)
|
||||
?.querySelector<HTMLElement>('button')
|
||||
?.focus()
|
||||
} else {
|
||||
layoutStore.showReactionsToolbar = true
|
||||
toggle()
|
||||
}
|
||||
}, [])
|
||||
}, [isOpen, toggle])
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'reaction',
|
||||
handler: handleShortcut,
|
||||
})
|
||||
useRegisterKeyboardShortcut({ id: 'reaction', handler: handleShortcut })
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
id="reactions-toggle"
|
||||
data-attr="reactions-toggle"
|
||||
ref={buttonRef}
|
||||
id={id}
|
||||
data-attr={id}
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('button')}
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { useRef, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFocusManager } from '@react-aria/focus'
|
||||
import { findFirstFocusable } from '@/utils/dom'
|
||||
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
|
||||
import { REACTIONS_TOOLBAR_ID } from '../../constants'
|
||||
|
||||
type Props = {
|
||||
children: ReactNode
|
||||
toggleId?: string
|
||||
controlBarId?: string
|
||||
}
|
||||
|
||||
export const ReactionsKeyboardNavigation = ({
|
||||
children,
|
||||
toggleId = 'reactions-toggle',
|
||||
controlBarId = 'control-bar',
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const focusManager = useFocusManager()
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const { close } = useReactionsToolbar()
|
||||
|
||||
const onFocus = (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
const fromOutside = !event.currentTarget.contains(event.relatedTarget)
|
||||
if (fromOutside) focusManager?.focusFirst()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const doc = rootRef.current?.ownerDocument ?? document
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowRight':
|
||||
focusManager?.focusNext({ wrap: true })
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
focusManager?.focusPrevious({ wrap: true })
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
doc.getElementById(toggleId)?.focus()
|
||||
close()
|
||||
break
|
||||
case 'Tab':
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
findFirstFocusable(doc.getElementById(controlBarId))?.focus()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
id={REACTIONS_TOOLBAR_ID}
|
||||
role="toolbar"
|
||||
aria-label={t('toolbar')}
|
||||
onFocus={onFocus}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ReactionButton } from './ReactionButton'
|
||||
import {
|
||||
computeReactionsPage,
|
||||
getMaxPageStart,
|
||||
} from '../../utils/reactionsPagination'
|
||||
|
||||
type Props = { isOpen: boolean; availableWidth: number }
|
||||
|
||||
export const ReactionsPill = ({ isOpen, availableWidth }: Props) => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'controls.reactions',
|
||||
})
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [pageStart, setPageStart] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setIsVisible(false)
|
||||
return
|
||||
}
|
||||
const id = requestAnimationFrame(() => setIsVisible(true))
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [isOpen])
|
||||
|
||||
const { visibleEmojis, hasOverflow, canGoLeft, canGoRight, visibleCount } =
|
||||
useMemo(
|
||||
() => computeReactionsPage(availableWidth, pageStart),
|
||||
[availableWidth, pageStart]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasOverflow) {
|
||||
setPageStart(0)
|
||||
return
|
||||
}
|
||||
const maxStart = getMaxPageStart(visibleCount)
|
||||
if (pageStart > maxStart) setPageStart(maxStart)
|
||||
}, [hasOverflow, pageStart, visibleCount])
|
||||
|
||||
const paginate = useCallback((direction: 'left' | 'right') => {
|
||||
setPageStart((current) =>
|
||||
direction === 'left' ? Math.max(0, current - 1) : current + 1
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Pill isVisible={isVisible}>
|
||||
{hasOverflow && (
|
||||
<ArrowSlot>
|
||||
<ArrowButton
|
||||
type="button"
|
||||
onClick={() => paginate('left')}
|
||||
aria-label={t('previousReactions')}
|
||||
disabled={!canGoLeft}
|
||||
>
|
||||
<RiArrowLeftSLine size={16} />
|
||||
</ArrowButton>
|
||||
</ArrowSlot>
|
||||
)}
|
||||
<EmojiRow>
|
||||
{visibleEmojis.map((emoji) => (
|
||||
<ReactionButton key={emoji} emoji={emoji} />
|
||||
))}
|
||||
</EmojiRow>
|
||||
{hasOverflow && (
|
||||
<ArrowSlot>
|
||||
<ArrowButton
|
||||
type="button"
|
||||
onClick={() => paginate('right')}
|
||||
aria-label={t('nextReactions')}
|
||||
disabled={!canGoRight}
|
||||
>
|
||||
<RiArrowRightSLine size={16} />
|
||||
</ArrowButton>
|
||||
</ArrowSlot>
|
||||
)}
|
||||
</Pill>
|
||||
)
|
||||
}
|
||||
|
||||
const Pill = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.2rem',
|
||||
borderRadius: '21px',
|
||||
padding: '0.15rem',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
width: 'fit-content',
|
||||
opacity: 0,
|
||||
transform: 'translateY(3.25rem)',
|
||||
transition: 'opacity, transform',
|
||||
transitionDuration: '0.5s',
|
||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
variants: {
|
||||
isVisible: {
|
||||
true: {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const EmojiRow = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
gap: '0.2rem',
|
||||
'& > *': {
|
||||
flexShrink: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const ArrowSlot = styled('div', {
|
||||
base: {
|
||||
width: '32px',
|
||||
minWidth: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
const ArrowButton = styled('button', {
|
||||
base: {
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
backgroundColor: 'primaryDark.200',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
opacity: 0.85,
|
||||
_hover: {
|
||||
opacity: 1,
|
||||
backgroundColor: 'primaryDark.300',
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.3,
|
||||
cursor: 'default',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,155 +1,69 @@
|
||||
import { FocusScope, useFocusManager } from '@react-aria/focus'
|
||||
import { REACTIONS_TOOLBAR_ID } from '../../constants'
|
||||
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
|
||||
import { ReactionButton } from './ReactionButton'
|
||||
import { Emoji } from '../../types'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { FocusScope } from '@react-aria/focus'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { getFirstControlBarFocusable } from '@/utils/dom'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
|
||||
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePipElementSize } from '@/features/pip/hooks/usePipElementSize'
|
||||
import { ReactionsKeyboardNavigation } from './ReactionsKeyboardNavigation'
|
||||
import { ReactionsPill } from './ReactionsPill'
|
||||
|
||||
const Container = styled('div', {
|
||||
type ReactionsToolbarProps = {
|
||||
toggleId?: string
|
||||
controlBarId?: string
|
||||
}
|
||||
|
||||
export const ReactionsToolbar = ({
|
||||
toggleId = 'reactions-toggle',
|
||||
controlBarId = 'control-bar',
|
||||
}: ReactionsToolbarProps) => {
|
||||
const { isOpen } = useReactionsToolbar()
|
||||
const renderContent = useDelayUnmount(isOpen, 500)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const { width: availableWidth } = usePipElementSize(wrapperRef)
|
||||
|
||||
useEffect(() => {
|
||||
const el = contentRef.current
|
||||
if (!el) return
|
||||
if (isOpen) el.removeAttribute('inert')
|
||||
else el.setAttribute('inert', '')
|
||||
}, [isOpen, renderContent])
|
||||
|
||||
return (
|
||||
<Wrapper ref={wrapperRef} isOpen={isOpen}>
|
||||
{renderContent && (
|
||||
<div ref={contentRef}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<FocusScope autoFocus>
|
||||
<ReactionsKeyboardNavigation
|
||||
toggleId={toggleId}
|
||||
controlBarId={controlBarId}
|
||||
>
|
||||
<ReactionsPill isOpen={isOpen} availableWidth={availableWidth} />
|
||||
</ReactionsKeyboardNavigation>
|
||||
</FocusScope>
|
||||
</div>
|
||||
)}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const Wrapper = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
bottom: 'var(--sizes-room-control-bar)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
})
|
||||
|
||||
const StyledStrip = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
gap: '0.2rem',
|
||||
borderRadius: '21px',
|
||||
padding: '0.15rem',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
opacity: 0,
|
||||
transform: 'translateY(3.25rem)',
|
||||
transition: 'opacity, transform',
|
||||
transitionDuration: '0.5s',
|
||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
pointerEvents: 'none',
|
||||
overflow: 'hidden',
|
||||
maxHeight: 0,
|
||||
width: '100%',
|
||||
transition:
|
||||
'max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), padding 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
variants: {
|
||||
isVisible: {
|
||||
isOpen: {
|
||||
true: {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
},
|
||||
desktopOffset: {
|
||||
true: {
|
||||
// Ideally this value should be calculated dynamically in JavaScript to keep
|
||||
// the reaction toolbar perfectly centered relative to the reaction toggle.
|
||||
// However, for simplicity and to follow a pragmatic 80/20 approach,
|
||||
// this value is currently hardcoded in CSS.
|
||||
marginRight: '30px',
|
||||
maxHeight: '60px',
|
||||
padding: '0.5rem 0',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const Strip = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isOpen } = useReactionsToolbar()
|
||||
const isMobile = useIsMobile()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// defer one frame so the browser paints opacity:0 first
|
||||
const id = requestAnimationFrame(() => setIsVisible(true))
|
||||
return () => cancelAnimationFrame(id)
|
||||
} else {
|
||||
setIsVisible(false)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<StyledStrip
|
||||
ref={ref}
|
||||
aria-hidden={!isOpen}
|
||||
isVisible={isVisible}
|
||||
desktopOffset={!isMobile}
|
||||
>
|
||||
{children}
|
||||
</StyledStrip>
|
||||
)
|
||||
}
|
||||
|
||||
const KeyboardNavigation = ({ children }: { children: React.ReactNode }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const focusManager = useFocusManager()
|
||||
|
||||
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
const comingFromOutside = !e.currentTarget.contains(e.relatedTarget)
|
||||
if (comingFromOutside) {
|
||||
focusManager?.focusFirst()
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
focusManager?.focusNext({ wrap: true })
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
focusManager?.focusPrevious({ wrap: true })
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
document.getElementById('reactions-toggle')?.focus()
|
||||
layoutStore.showReactionsToolbar = false
|
||||
break
|
||||
case 'Tab':
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault()
|
||||
getFirstControlBarFocusable('control-bar')?.focus()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={REACTIONS_TOOLBAR_ID}
|
||||
role="toolbar"
|
||||
aria-label={t('toolbar')}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ReactionsToolbar = () => {
|
||||
const { isOpen } = useReactionsToolbar()
|
||||
const shouldMount = useDelayUnmount(isOpen, 300)
|
||||
|
||||
if (!shouldMount) return null
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
|
||||
<FocusScope autoFocus>
|
||||
<KeyboardNavigation>
|
||||
<Strip>
|
||||
{Object.values(Emoji).map((emoji) => (
|
||||
<ReactionButton key={emoji} emoji={emoji} />
|
||||
))}
|
||||
</Strip>
|
||||
</KeyboardNavigation>
|
||||
</FocusScope>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
|
||||
export type ReactionsToolbarStore = {
|
||||
showReactionsToolbar: boolean
|
||||
}
|
||||
|
||||
const ReactionsToolbarStoreContext =
|
||||
createContext<ReactionsToolbarStore>(layoutStore)
|
||||
|
||||
export const ReactionsToolbarStoreProvider =
|
||||
ReactionsToolbarStoreContext.Provider
|
||||
|
||||
export const useReactionsToolbar = () => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const store = useContext(ReactionsToolbarStoreContext)
|
||||
const snap = useSnapshot(store)
|
||||
|
||||
return {
|
||||
isOpen: layoutSnap.showReactionsToolbar,
|
||||
isOpen: snap.showReactionsToolbar,
|
||||
toggle: () => {
|
||||
layoutStore.showReactionsToolbar = !layoutSnap.showReactionsToolbar
|
||||
store.showReactionsToolbar = !store.showReactionsToolbar
|
||||
},
|
||||
close: () => {
|
||||
store.showReactionsToolbar = false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Emoji } from '@/features/reactions/types'
|
||||
|
||||
export const EMOJI_SLOT_WIDTH = 40
|
||||
export const ARROW_SLOT_WIDTH = 32
|
||||
export const PILL_HORIZONTAL_PADDING = 12
|
||||
export const WRAPPER_HORIZONTAL_PADDING = 16
|
||||
|
||||
const EMOJIS = Object.values(Emoji)
|
||||
|
||||
export type ReactionsPage = {
|
||||
visibleEmojis: Emoji[]
|
||||
hasOverflow: boolean
|
||||
canGoLeft: boolean
|
||||
canGoRight: boolean
|
||||
visibleCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute how many emojis fit in `availableWidth` and slice the visible page.
|
||||
* Arrow slots are reserved only when the list overflows.
|
||||
*/
|
||||
export const computeReactionsPage = (
|
||||
availableWidth: number,
|
||||
pageStart: number
|
||||
): ReactionsPage => {
|
||||
const usableWidth =
|
||||
availableWidth - WRAPPER_HORIZONTAL_PADDING - PILL_HORIZONTAL_PADDING
|
||||
const maxWithoutArrows = Math.max(
|
||||
1,
|
||||
Math.floor(usableWidth / EMOJI_SLOT_WIDTH)
|
||||
)
|
||||
|
||||
if (EMOJIS.length <= maxWithoutArrows) {
|
||||
return {
|
||||
visibleEmojis: EMOJIS,
|
||||
hasOverflow: false,
|
||||
canGoLeft: false,
|
||||
canGoRight: false,
|
||||
visibleCount: EMOJIS.length,
|
||||
}
|
||||
}
|
||||
|
||||
const visibleCount = Math.max(
|
||||
1,
|
||||
Math.floor((usableWidth - ARROW_SLOT_WIDTH * 2) / EMOJI_SLOT_WIDTH)
|
||||
)
|
||||
const clampedStart = Math.min(
|
||||
Math.max(0, pageStart),
|
||||
Math.max(0, EMOJIS.length - visibleCount)
|
||||
)
|
||||
|
||||
return {
|
||||
visibleEmojis: EMOJIS.slice(clampedStart, clampedStart + visibleCount),
|
||||
hasOverflow: true,
|
||||
canGoLeft: clampedStart > 0,
|
||||
canGoRight: clampedStart + visibleCount < EMOJIS.length,
|
||||
visibleCount,
|
||||
}
|
||||
}
|
||||
|
||||
export const getMaxPageStart = (visibleCount: number): number =>
|
||||
Math.max(0, EMOJIS.length - visibleCount)
|
||||
@@ -1,4 +1,3 @@
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { text } from '@/primitives/Text'
|
||||
@@ -156,6 +155,8 @@ export const SidePanel = () => {
|
||||
isInfoOpen,
|
||||
isSubPanelOpen,
|
||||
activeSubPanelId,
|
||||
closePanel,
|
||||
goBack,
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
const title = t(`heading.${activeSubPanelId || activePanelId}`)
|
||||
@@ -166,10 +167,7 @@ export const SidePanel = () => {
|
||||
<StyledSidePanel
|
||||
title={title}
|
||||
ariaLabel={t('ariaLabel', { title })}
|
||||
onClose={() => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
}}
|
||||
onClose={closePanel}
|
||||
closeButtonTooltip={t('closeButton', {
|
||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||
})}
|
||||
@@ -177,7 +175,7 @@ export const SidePanel = () => {
|
||||
isSubmenu={isSubPanelOpen}
|
||||
isReactionToolbarOpen={isReactionToolbarOpen}
|
||||
backButtonLabel={t('backToTools')}
|
||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||
onBack={goBack}
|
||||
>
|
||||
<Panel isOpen={isParticipantsOpen}>
|
||||
<ParticipantsList />
|
||||
|
||||
+2
@@ -7,6 +7,7 @@ import { EffectsMenuItem } from './EffectsMenuItem'
|
||||
import { SupportMenuItem } from './SupportMenuItem'
|
||||
import { TranscriptMenuItem } from './TranscriptMenuItem'
|
||||
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
|
||||
import { PictureInPictureMenuItem } from './PictureInPictureMenuItem'
|
||||
|
||||
// @todo try refactoring it to use MenuList component
|
||||
export const OptionsMenuItems = () => {
|
||||
@@ -21,6 +22,7 @@ export const OptionsMenuItems = () => {
|
||||
<TranscriptMenuItem />
|
||||
<ScreenRecordingMenuItem />
|
||||
<FullScreenMenuItem />
|
||||
<PictureInPictureMenuItem />
|
||||
<EffectsMenuItem />
|
||||
</MenuSection>
|
||||
<Separator />
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiPictureInPicture2Line } from '@remixicon/react'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useRoomPiP } from '@/features/pip/hooks/useRoomPiP'
|
||||
|
||||
export const PictureInPictureMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { isSupported, isOpen, toggle } = useRoomPiP()
|
||||
|
||||
// Hide the entry when the browser doesn't support Document PiP.
|
||||
if (!isSupported) return null
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
onAction={toggle}
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
<RiPictureInPicture2Line size={20} />
|
||||
{isOpen ? t('pictureInPicture.exit') : t('pictureInPicture.enter')}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +1,84 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { PanelId, SubPanelId } from '../types/panel'
|
||||
|
||||
export enum PanelId {
|
||||
PARTICIPANTS = 'participants',
|
||||
EFFECTS = 'effects',
|
||||
CHAT = 'chat',
|
||||
TOOLS = 'tools',
|
||||
ADMIN = 'admin',
|
||||
INFO = 'info',
|
||||
export { PanelId, SubPanelId } from '../types/panel'
|
||||
|
||||
export type SidePanelStore = {
|
||||
activePanelId: PanelId | null
|
||||
activeSubPanelId: SubPanelId | null
|
||||
}
|
||||
|
||||
export enum SubPanelId {
|
||||
TRANSCRIPT = 'transcript',
|
||||
SCREEN_RECORDING = 'screenRecording',
|
||||
}
|
||||
const SidePanelStoreContext = createContext<SidePanelStore>(layoutStore)
|
||||
|
||||
export const useSidePanel = () => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
export const SidePanelStoreProvider = SidePanelStoreContext.Provider
|
||||
|
||||
export const useSidePanel = (store?: SidePanelStore) => {
|
||||
const currentStore = useContext(SidePanelStoreContext)
|
||||
store ??= currentStore
|
||||
const layoutSnap = useSnapshot(store)
|
||||
const activePanelId = layoutSnap.activePanelId
|
||||
const activeSubPanelId = layoutSnap.activeSubPanelId
|
||||
|
||||
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
|
||||
const isEffectsOpen = activePanelId == PanelId.EFFECTS
|
||||
const isChatOpen = activePanelId == PanelId.CHAT
|
||||
const isToolsOpen = activePanelId == PanelId.TOOLS
|
||||
const isAdminOpen = activePanelId == PanelId.ADMIN
|
||||
const isInfoOpen = activePanelId == PanelId.INFO
|
||||
const isTranscriptOpen = activeSubPanelId == SubPanelId.TRANSCRIPT
|
||||
const isScreenRecordingOpen = activeSubPanelId == SubPanelId.SCREEN_RECORDING
|
||||
const isParticipantsOpen = activePanelId === PanelId.PARTICIPANTS
|
||||
const isEffectsOpen = activePanelId === PanelId.EFFECTS
|
||||
const isChatOpen = activePanelId === PanelId.CHAT
|
||||
const isToolsOpen = activePanelId === PanelId.TOOLS
|
||||
const isAdminOpen = activePanelId === PanelId.ADMIN
|
||||
const isInfoOpen = activePanelId === PanelId.INFO
|
||||
const isTranscriptOpen = activeSubPanelId === SubPanelId.TRANSCRIPT
|
||||
const isScreenRecordingOpen = activeSubPanelId === SubPanelId.SCREEN_RECORDING
|
||||
const isSidePanelOpen = !!activePanelId
|
||||
const isSubPanelOpen = !!activeSubPanelId
|
||||
|
||||
const toggleAdmin = () => {
|
||||
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
store.activePanelId = isAdminOpen ? null : PanelId.ADMIN
|
||||
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleParticipants = () => {
|
||||
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
store.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
|
||||
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleChat = () => {
|
||||
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
store.activePanelId = isChatOpen ? null : PanelId.CHAT
|
||||
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleEffects = () => {
|
||||
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
store.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
|
||||
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleTools = () => {
|
||||
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
store.activePanelId = isToolsOpen ? null : PanelId.TOOLS
|
||||
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleInfo = () => {
|
||||
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
store.activePanelId = isInfoOpen ? null : PanelId.INFO
|
||||
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const openTranscript = () => {
|
||||
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
|
||||
layoutStore.activePanelId = PanelId.TOOLS
|
||||
store.activeSubPanelId = SubPanelId.TRANSCRIPT
|
||||
store.activePanelId = PanelId.TOOLS
|
||||
}
|
||||
|
||||
const openScreenRecording = () => {
|
||||
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
|
||||
layoutStore.activePanelId = PanelId.TOOLS
|
||||
store.activeSubPanelId = SubPanelId.SCREEN_RECORDING
|
||||
store.activePanelId = PanelId.TOOLS
|
||||
}
|
||||
|
||||
const closePanel = () => {
|
||||
store.activePanelId = null
|
||||
store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -82,6 +92,8 @@ export const useSidePanel = () => {
|
||||
toggleInfo,
|
||||
openTranscript,
|
||||
openScreenRecording,
|
||||
closePanel,
|
||||
goBack,
|
||||
isSubPanelOpen,
|
||||
isChatOpen,
|
||||
isParticipantsOpen,
|
||||
|
||||
@@ -16,17 +16,20 @@ import { VideoDeviceControl } from '../../components/controls/Device/VideoDevice
|
||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
|
||||
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
|
||||
import { useRoomPiP } from '@/features/pip/hooks/useRoomPiP'
|
||||
|
||||
export function DesktopControlBar({
|
||||
onDeviceError,
|
||||
}: Readonly<ControlBarAuxProps>) {
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||
const { isOpen: isPiPOpen } = useRoomPiP()
|
||||
|
||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'focus-toolbar',
|
||||
isDisabled: isPiPOpen,
|
||||
handler: () => {
|
||||
const root = desktopControlBarEl.current
|
||||
if (!root) return
|
||||
|
||||
@@ -34,6 +34,9 @@ import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||
import { RoomPiP } from '@/features/pip/components/RoomPiP'
|
||||
import { PipPlaceholder } from '@/features/pip/components/PipPlaceholder'
|
||||
import { useRoomPiP } from '@/features/pip/hooks/useRoomPiP'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
||||
@@ -227,6 +230,8 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
])
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
const { isOpen: isPiPOpen } = useRoomPiP()
|
||||
|
||||
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -248,32 +253,38 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
/>
|
||||
<IsIdleDisconnectModal />
|
||||
<RoomContentArea>
|
||||
{!focusTrack ? (
|
||||
<div
|
||||
className="lk-grid-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
</div>
|
||||
{isPiPOpen ? (
|
||||
<PipPlaceholder />
|
||||
) : (
|
||||
<div
|
||||
className="lk-focus-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||
<CarouselLayout
|
||||
tracks={carouselTracks}
|
||||
style={{
|
||||
minWidth: '200px',
|
||||
}}
|
||||
<>
|
||||
{!focusTrack ? (
|
||||
<div
|
||||
className="lk-grid-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<ParticipantTile />
|
||||
</CarouselLayout>
|
||||
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||
</FocusLayoutContainer>
|
||||
</div>
|
||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="lk-focus-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||
<CarouselLayout
|
||||
tracks={carouselTracks}
|
||||
style={{
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<ParticipantTile />
|
||||
</CarouselLayout>
|
||||
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||
</FocusLayoutContainer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</RoomContentArea>
|
||||
<ControlBar
|
||||
@@ -289,6 +300,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
}}
|
||||
/>
|
||||
<SidePanel />
|
||||
<RoomPiP />
|
||||
</LayoutContextProvider>
|
||||
)}
|
||||
<RoomAudioRenderer />
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Panel identifiers for the side panel (Info, Chat, Participants, etc.).
|
||||
* Extracted to avoid circular dependencies between layout store and useSidePanel.
|
||||
*/
|
||||
export enum PanelId {
|
||||
PARTICIPANTS = 'participants',
|
||||
EFFECTS = 'effects',
|
||||
CHAT = 'chat',
|
||||
TOOLS = 'tools',
|
||||
ADMIN = 'admin',
|
||||
INFO = 'info',
|
||||
}
|
||||
|
||||
export enum SubPanelId {
|
||||
TRANSCRIPT = 'transcript',
|
||||
SCREEN_RECORDING = 'screenRecording',
|
||||
}
|
||||
@@ -19,10 +19,14 @@ export const useRegisterKeyboardShortcut = ({
|
||||
const descriptor = getShortcutDescriptorById(id)
|
||||
if (!descriptor?.shortcut) return
|
||||
const formattedKey = formatShortcutKey(descriptor.shortcut)
|
||||
if (isDisabled) {
|
||||
keyboardShortcutsStore.shortcuts.delete(formattedKey)
|
||||
} else {
|
||||
if (!isDisabled) {
|
||||
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
|
||||
}
|
||||
return () => {
|
||||
// Remove only if this is still the registered handler
|
||||
if (keyboardShortcutsStore.shortcuts.get(formattedKey) === handler) {
|
||||
keyboardShortcutsStore.shortcuts.delete(formattedKey)
|
||||
}
|
||||
}
|
||||
}, [handler, id, isDisabled])
|
||||
}
|
||||
|
||||
@@ -219,6 +219,8 @@
|
||||
"toolbar": "Reaktion senden",
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "du",
|
||||
"previousReactions": "Vorherige Reaktionen",
|
||||
"nextReactions": "Nächste Reaktionen",
|
||||
"emojis": {
|
||||
"thumbs-up": "Daumen hoch",
|
||||
"thumbs-down": "Daumen runter",
|
||||
@@ -241,6 +243,26 @@
|
||||
"username": "Deinen Namen aktualisieren",
|
||||
"effects": "Effekte anwenden",
|
||||
"switchCamera": "Kamera wechseln",
|
||||
"pictureInPicture": {
|
||||
"enter": "Bild-im-Bild",
|
||||
"exit": "Bild-im-Bild schließen",
|
||||
"opened": "Bild-im-Bild-Modus aktiviert",
|
||||
"closed": "Bild-im-Bild-Modus deaktiviert",
|
||||
"windowLabel": "Bild-im-Bild Besprechung",
|
||||
"stage": "Teilnehmer",
|
||||
"controlBar": "Besprechungssteuerung",
|
||||
"notificationsLabel": "Benachrichtigungen",
|
||||
"dismissNotification": "Benachrichtigung schließen",
|
||||
"connection": {
|
||||
"reconnecting": "Verbindung wird wiederhergestellt…",
|
||||
"disconnected": "Verbindung getrennt"
|
||||
},
|
||||
"placeholder": {
|
||||
"title": "Ihr Videoanruf befindet sich in einem anderen Fenster.",
|
||||
"description": "Im Bild-im-Bild-Modus bleiben Sie mit dem Anruf verbunden, während Sie andere Aufgaben erledigen.",
|
||||
"bringBack": "Anruf hierher zurückholen"
|
||||
}
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Vollbild",
|
||||
"exit": "Vollbildmodus verlassen"
|
||||
|
||||
@@ -219,6 +219,8 @@
|
||||
"toolbar": "Send reaction",
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "you",
|
||||
"previousReactions": "Previous reactions",
|
||||
"nextReactions": "Next reactions",
|
||||
"emojis": {
|
||||
"thumbs-up": "thumbs up",
|
||||
"thumbs-down": "thumbs down",
|
||||
@@ -241,6 +243,26 @@
|
||||
"username": "Update Your Name",
|
||||
"effects": "Backgrounds and Effects",
|
||||
"switchCamera": "Switch camera",
|
||||
"pictureInPicture": {
|
||||
"enter": "Picture-in-picture",
|
||||
"exit": "Close picture-in-picture",
|
||||
"opened": "Picture-in-picture mode enabled",
|
||||
"closed": "Picture-in-picture mode disabled",
|
||||
"windowLabel": "Picture-in-picture meeting",
|
||||
"stage": "Participants",
|
||||
"controlBar": "Meeting controls",
|
||||
"notificationsLabel": "Notifications",
|
||||
"dismissNotification": "Dismiss notification",
|
||||
"connection": {
|
||||
"reconnecting": "Reconnecting…",
|
||||
"disconnected": "Disconnected"
|
||||
},
|
||||
"placeholder": {
|
||||
"title": "Your video call is in another window.",
|
||||
"description": "Picture-in-Picture mode allows you to stay connected to the call while performing other tasks.",
|
||||
"bringBack": "Bring the call back here"
|
||||
}
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Fullscreen",
|
||||
"exit": "Exit fullscreen mode"
|
||||
|
||||
@@ -219,6 +219,8 @@
|
||||
"toolbar": "Envoyer une réaction",
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "vous",
|
||||
"previousReactions": "Réactions précédentes",
|
||||
"nextReactions": "Réactions suivantes",
|
||||
"emojis": {
|
||||
"thumbs-up": "pouce levé",
|
||||
"thumbs-down": "pouce baissé",
|
||||
@@ -241,6 +243,26 @@
|
||||
"username": "Choisir votre nom",
|
||||
"effects": "Arrière-plans et effets",
|
||||
"switchCamera": "Changer de caméra",
|
||||
"pictureInPicture": {
|
||||
"enter": "Image dans l'image",
|
||||
"exit": "Fermer l'image dans l'image",
|
||||
"opened": "Mode image dans l'image activé",
|
||||
"closed": "Mode image dans l'image désactivé",
|
||||
"windowLabel": "Réunion en image dans l'image",
|
||||
"stage": "Participants",
|
||||
"controlBar": "Commandes de la réunion",
|
||||
"notificationsLabel": "Notifications",
|
||||
"dismissNotification": "Fermer la notification",
|
||||
"connection": {
|
||||
"reconnecting": "Reconnexion…",
|
||||
"disconnected": "Déconnecté"
|
||||
},
|
||||
"placeholder": {
|
||||
"title": "Votre appel vidéo est dans une autre fenêtre.",
|
||||
"description": "Le mode image dans l'image vous permet de rester connecté à l'appel tout en effectuant d'autres tâches.",
|
||||
"bringBack": "Ramener l'appel ici"
|
||||
}
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Plein écran",
|
||||
"exit": "Quitter le mode plein écran"
|
||||
|
||||
@@ -219,6 +219,8 @@
|
||||
"toolbar": "Stuur reactie",
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "U",
|
||||
"previousReactions": "Vorige reacties",
|
||||
"nextReactions": "Volgende reacties",
|
||||
"emojis": {
|
||||
"thumbs-up": "duim omhoog",
|
||||
"thumbs-down": "duim omlaag",
|
||||
@@ -241,6 +243,26 @@
|
||||
"username": "Verander uw naam",
|
||||
"effects": "Pas effecten toe",
|
||||
"switchCamera": "Selecteer camera",
|
||||
"pictureInPicture": {
|
||||
"enter": "Beeld-in-beeld",
|
||||
"exit": "Beeld-in-beeld sluiten",
|
||||
"opened": "Beeld-in-beeld-modus ingeschakeld",
|
||||
"closed": "Beeld-in-beeld-modus uitgeschakeld",
|
||||
"windowLabel": "Beeld-in-beeld vergadering",
|
||||
"stage": "Deelnemers",
|
||||
"controlBar": "Vergaderbesturing",
|
||||
"notificationsLabel": "Meldingen",
|
||||
"dismissNotification": "Melding sluiten",
|
||||
"connection": {
|
||||
"reconnecting": "Opnieuw verbinden…",
|
||||
"disconnected": "Verbinding verbroken"
|
||||
},
|
||||
"placeholder": {
|
||||
"title": "Uw videogesprek bevindt zich in een ander venster.",
|
||||
"description": "Met de beeld-in-beeld-modus kunt u verbonden blijven met het gesprek terwijl u andere taken uitvoert.",
|
||||
"bringBack": "Gesprek hier terughalen"
|
||||
}
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Volledig scherm",
|
||||
"exit": "Stop volledig scherm stand"
|
||||
|
||||
@@ -2,24 +2,29 @@ import { ReactNode } from 'react'
|
||||
import { MenuTrigger } from 'react-aria-components'
|
||||
import { StyledPopover } from './Popover'
|
||||
import { Box } from './Box'
|
||||
import { useOverlayBoundaryElement } from './useOverlayPortalContainer'
|
||||
|
||||
/**
|
||||
* a Menu is a tuple of a trigger component (most usually a Button) that toggles menu items in a tooltip around the trigger
|
||||
*
|
||||
* Uses UNSAFE_PortalProvider context automatically for portal container (no need for UNSTABLE_portalContainer).
|
||||
*/
|
||||
export const Menu = ({
|
||||
children,
|
||||
variant = 'light',
|
||||
placement,
|
||||
placement = 'top',
|
||||
}: {
|
||||
children: [trigger: ReactNode, menu: ReactNode]
|
||||
variant?: 'dark' | 'light'
|
||||
placement?: 'bottom' | 'top' | 'left' | 'right'
|
||||
}) => {
|
||||
const [trigger, menu] = children
|
||||
const boundaryElement = useOverlayBoundaryElement()
|
||||
|
||||
return (
|
||||
<MenuTrigger>
|
||||
{trigger}
|
||||
<StyledPopover placement={placement}>
|
||||
<StyledPopover placement={placement} boundaryElement={boundaryElement}>
|
||||
<Box size="sm" type="popover" variant={variant}>
|
||||
{menu}
|
||||
</Box>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'react-aria-components'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Box } from './Box'
|
||||
import { useOverlayBoundaryElement } from './useOverlayPortalContainer'
|
||||
|
||||
export const StyledPopover = styled(RACPopover, {
|
||||
base: {
|
||||
@@ -65,6 +66,8 @@ const StyledOverlayArrow = styled(OverlayArrow, {
|
||||
*
|
||||
* Note: to show a list of actionable items, like a dropdown menu, prefer using a <Menu> or <Select>.
|
||||
* This is here when needing to show unrestricted content in a box.
|
||||
*
|
||||
* Uses UNSAFE_PortalProvider context automatically for portal container (no need for UNSTABLE_portalContainer).
|
||||
*/
|
||||
export const Popover = ({
|
||||
children,
|
||||
@@ -82,10 +85,11 @@ export const Popover = ({
|
||||
withArrow?: boolean
|
||||
} & Omit<DialogProps, 'children'>) => {
|
||||
const [trigger, popoverContent] = children
|
||||
const boundaryElement = useOverlayBoundaryElement()
|
||||
return (
|
||||
<DialogTrigger>
|
||||
{trigger}
|
||||
<StyledPopover>
|
||||
<StyledPopover boundaryElement={boundaryElement}>
|
||||
{withArrow && (
|
||||
<StyledOverlayArrow variant={variant}>
|
||||
<svg width={12} height={12} viewBox="0 0 12 12">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { type ReactElement, type ReactNode } from 'react'
|
||||
import {
|
||||
OverlayArrow,
|
||||
Tooltip as RACTooltip,
|
||||
@@ -6,12 +6,17 @@ import {
|
||||
type TooltipProps,
|
||||
} from 'react-aria-components'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { VisualOnlyTooltip } from './VisualOnlyTooltip'
|
||||
import { useVisualOnlyTooltips } from './VisualOnlyTooltipsContext'
|
||||
|
||||
export type TooltipWrapperProps = {
|
||||
tooltip?: string
|
||||
tooltipType?: 'instant' | 'delayed'
|
||||
}
|
||||
|
||||
const INSTANT_TOOLTIP_DELAY_MS = 150
|
||||
const DELAYED_TOOLTIP_DELAY_MS = 1000
|
||||
|
||||
/**
|
||||
* Wrap a component you want to apply a tooltip on (for example a Button)
|
||||
*
|
||||
@@ -22,15 +27,25 @@ export const TooltipWrapper = ({
|
||||
tooltipType,
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
children: ReactElement
|
||||
} & TooltipWrapperProps) => {
|
||||
return tooltip ? (
|
||||
<TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}>
|
||||
const visualOnly = useVisualOnlyTooltips()
|
||||
const tooltipDelay =
|
||||
tooltipType === 'instant'
|
||||
? INSTANT_TOOLTIP_DELAY_MS
|
||||
: DELAYED_TOOLTIP_DELAY_MS
|
||||
|
||||
if (!tooltip) return children
|
||||
|
||||
if (visualOnly) {
|
||||
return <VisualOnlyTooltip tooltip={tooltip}>{children}</VisualOnlyTooltip>
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipTrigger delay={tooltipDelay}>
|
||||
{children}
|
||||
<Tooltip>{tooltip}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
) : (
|
||||
children
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,6 +54,8 @@ export const TooltipWrapper = ({
|
||||
*
|
||||
* Style taken from example at https://react-spectrum.adobe.com/react-aria/Tooltip.html
|
||||
*/
|
||||
const DEFAULT_TOOLTIP_GAP_PX = 8
|
||||
|
||||
const StyledTooltip = styled(RACTooltip, {
|
||||
base: {
|
||||
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
|
||||
@@ -53,11 +70,11 @@ const StyledTooltip = styled(RACTooltip, {
|
||||
fontSize: 14,
|
||||
transform: 'translate3d(0, 0, 0)',
|
||||
'&[data-placement=top]': {
|
||||
marginBottom: '8px',
|
||||
marginBottom: `${DEFAULT_TOOLTIP_GAP_PX}px`,
|
||||
'--origin': 'translateY(4px)',
|
||||
},
|
||||
'&[data-placement=bottom]': {
|
||||
marginTop: '8px',
|
||||
marginTop: `${DEFAULT_TOOLTIP_GAP_PX}px`,
|
||||
'--origin': 'translateY(-4px)',
|
||||
},
|
||||
'&[data-placement=right]': {
|
||||
@@ -107,10 +124,13 @@ const TooltipArrow = () => {
|
||||
|
||||
const Tooltip = ({
|
||||
children,
|
||||
arrowBoundaryOffset,
|
||||
...props
|
||||
}: Omit<TooltipProps, 'children'> & { children: ReactNode }) => {
|
||||
}: {
|
||||
children: ReactNode
|
||||
} & Partial<Omit<TooltipProps, 'children'>>) => {
|
||||
return (
|
||||
<StyledTooltip {...props}>
|
||||
<StyledTooltip arrowBoundaryOffset={arrowBoundaryOffset ?? 0} {...props}>
|
||||
<TooltipArrow />
|
||||
{children}
|
||||
</StyledTooltip>
|
||||
|
||||
@@ -2,11 +2,14 @@ import {
|
||||
type ReactElement,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useUNSAFE_PortalContext } from '@react-aria/overlays'
|
||||
|
||||
export type VisualOnlyTooltipProps = {
|
||||
children: ReactElement
|
||||
@@ -32,19 +35,29 @@ export const VisualOnlyTooltip = ({
|
||||
tooltipPosition = 'top',
|
||||
}: VisualOnlyTooltipProps) => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const { getContainer } = useUNSAFE_PortalContext()
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const tooltipRef = useRef<HTMLDivElement>(null)
|
||||
const [position, setPosition] = useState<{
|
||||
top: number
|
||||
left: number
|
||||
} | null>(null)
|
||||
const [computedStyle, setComputedStyle] = useState<{
|
||||
left: number
|
||||
arrowLeft: number
|
||||
} | null>(null)
|
||||
|
||||
const isBottom = tooltipPosition === 'bottom'
|
||||
const [effectiveBottom, setEffectiveBottom] = useState(
|
||||
tooltipPosition === 'bottom'
|
||||
)
|
||||
|
||||
const showTooltip = () => {
|
||||
if (!wrapperRef.current) return
|
||||
const rect = wrapperRef.current.getBoundingClientRect()
|
||||
const preferBottom = tooltipPosition === 'bottom'
|
||||
setEffectiveBottom(preferBottom)
|
||||
setPosition({
|
||||
top: isBottom ? rect.bottom + 8 : rect.top - 8,
|
||||
top: preferBottom ? rect.bottom + 8 : rect.top - 8,
|
||||
left: rect.left + rect.width / 2,
|
||||
})
|
||||
setIsVisible(true)
|
||||
@@ -53,15 +66,67 @@ export const VisualOnlyTooltip = ({
|
||||
const hideTooltip = () => {
|
||||
setIsVisible(false)
|
||||
setPosition(null)
|
||||
setComputedStyle(null)
|
||||
}
|
||||
|
||||
const tooltipData = isVisible && position ? { isVisible, position } : null
|
||||
useLayoutEffect(() => {
|
||||
if (!tooltipRef.current || !wrapperRef.current || !isVisible || !position)
|
||||
return
|
||||
const tooltipRect = tooltipRef.current.getBoundingClientRect()
|
||||
const triggerRect = wrapperRef.current.getBoundingClientRect()
|
||||
const doc = tooltipRef.current.ownerDocument
|
||||
const viewportWidth = doc.defaultView?.innerWidth ?? globalThis.innerWidth
|
||||
const padding = 8
|
||||
|
||||
// Vertical flip: if tooltip overflows the top, switch to bottom
|
||||
if (!effectiveBottom && position.top - tooltipRect.height < 0) {
|
||||
const flippedTop = triggerRect.bottom + 8
|
||||
setEffectiveBottom(true)
|
||||
setPosition({ top: flippedTop, left: position.left })
|
||||
return
|
||||
}
|
||||
|
||||
// Horizontal clamping (both edges)
|
||||
const desiredLeft = position.left - tooltipRect.width / 2
|
||||
const minLeft = padding
|
||||
const maxLeft = viewportWidth - padding - tooltipRect.width
|
||||
|
||||
if (desiredLeft >= minLeft && desiredLeft <= maxLeft) {
|
||||
setComputedStyle(null)
|
||||
return
|
||||
}
|
||||
|
||||
const clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft))
|
||||
setComputedStyle({
|
||||
left: clampedLeft,
|
||||
arrowLeft: position.left - clampedLeft,
|
||||
})
|
||||
}, [isVisible, position, effectiveBottom])
|
||||
|
||||
const portalContainer = useMemo(() => {
|
||||
if (getContainer) return getContainer()
|
||||
return wrapperRef.current?.ownerDocument?.body ?? document.body
|
||||
}, [getContainer])
|
||||
const wrappedChild = isValidElement(children)
|
||||
? cloneElement(children, {
|
||||
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
|
||||
})
|
||||
: children
|
||||
|
||||
const translateY = effectiveBottom ? 'translateY(0)' : 'translateY(-100%)'
|
||||
const translateXY = effectiveBottom
|
||||
? 'translate(-50%, 0)'
|
||||
: 'translate(-50%, -100%)'
|
||||
|
||||
const tooltipInlineStyle: React.CSSProperties & Record<string, string> = {
|
||||
top: `${position?.top}px`,
|
||||
left: computedStyle ? `${computedStyle.left}px` : `${position?.left}px`,
|
||||
transform: computedStyle ? translateY : translateXY,
|
||||
...(computedStyle
|
||||
? { '--tooltip-arrow-left': `${computedStyle.arrowLeft}px` }
|
||||
: null),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -73,11 +138,14 @@ export const VisualOnlyTooltip = ({
|
||||
>
|
||||
{wrappedChild}
|
||||
</div>
|
||||
{tooltipData &&
|
||||
{isVisible &&
|
||||
position &&
|
||||
portalContainer &&
|
||||
createPortal(
|
||||
<div
|
||||
aria-hidden="true"
|
||||
role="presentation"
|
||||
ref={tooltipRef}
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
padding: '2px 8px',
|
||||
@@ -87,15 +155,15 @@ export const VisualOnlyTooltip = ({
|
||||
fontSize: 14,
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9999,
|
||||
zIndex: 100001,
|
||||
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
left: 'var(--tooltip-arrow-left, 50%)',
|
||||
transform: 'translateX(-50%)',
|
||||
border: '4px solid transparent',
|
||||
...(isBottom
|
||||
...(effectiveBottom
|
||||
? {
|
||||
bottom: '100%',
|
||||
borderBottomColor: 'primaryDark.100',
|
||||
@@ -106,17 +174,11 @@ export const VisualOnlyTooltip = ({
|
||||
}),
|
||||
},
|
||||
})}
|
||||
style={{
|
||||
top: `${tooltipData.position.top}px`,
|
||||
left: `${tooltipData.position.left}px`,
|
||||
transform: isBottom
|
||||
? 'translate(-50%, 0)'
|
||||
: 'translate(-50%, -100%)',
|
||||
}}
|
||||
style={tooltipInlineStyle}
|
||||
>
|
||||
{tooltip}
|
||||
</div>,
|
||||
document.body
|
||||
portalContainer
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
/**
|
||||
* When true, tooltips render as visual-only (no aria-describedby).
|
||||
* Provided by surfaces where React Aria TooltipTrigger doesn't work
|
||||
* correctly (e.g. cross-document portals).
|
||||
*/
|
||||
export const VisualOnlyTooltipsContext = createContext(false)
|
||||
|
||||
export const useVisualOnlyTooltips = () => useContext(VisualOnlyTooltipsContext)
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useUNSAFE_PortalContext } from '@react-aria/overlays'
|
||||
|
||||
/**
|
||||
* Hook to retrieve the portal container for overlays (menus, tooltips, popovers).
|
||||
* Returns the container from UNSAFE_PortalProvider context (pip-root in PiP, undefined in main window).
|
||||
*/
|
||||
export const useOverlayPortalContainer = () => {
|
||||
const { getContainer } = useUNSAFE_PortalContext()
|
||||
|
||||
return useMemo(() => getContainer?.() ?? undefined, [getContainer])
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to retrieve the boundary element for overlay positioning.
|
||||
* Returns the portal container in PiP (for PiP-relative positioning), undefined in main window.
|
||||
*/
|
||||
export const useOverlayBoundaryElement = () => {
|
||||
const portalContainer = useOverlayPortalContainer()
|
||||
return portalContainer
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { proxy } from 'valtio'
|
||||
import {
|
||||
PanelId,
|
||||
SubPanelId,
|
||||
} from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { PanelId, SubPanelId } from '@/features/rooms/livekit/types/panel'
|
||||
|
||||
type State = {
|
||||
showHeader: boolean
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
type State = {
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
export const roomPiPStore = proxy<State>({
|
||||
isOpen: false,
|
||||
})
|
||||
@@ -1,6 +1,20 @@
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'input, select, textarea, button, object, a, area[href], [tabindex]'
|
||||
|
||||
/**
|
||||
* Find the first focusable descendant of `root`.
|
||||
* Works across documents (useful for the PiP window, which has its own
|
||||
* `document`). Pass the result of `ownerDocument.getElementById(...)` to
|
||||
* target an element in a specific document.
|
||||
*/
|
||||
export const findFirstFocusable = (
|
||||
root: HTMLElement | null | undefined
|
||||
): HTMLElement | null =>
|
||||
root?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? null
|
||||
|
||||
/**
|
||||
* Wrapper for the main document. Use `findFirstFocusable` when
|
||||
* working with a non-main document (e.g. the PiP window).
|
||||
*/
|
||||
export const getFirstControlBarFocusable = (id: string): HTMLElement | null =>
|
||||
document
|
||||
.getElementById(id)
|
||||
?.querySelector(
|
||||
'input, select, textarea, button, object, a, area[href], [tabindex]'
|
||||
) ?? null
|
||||
findFirstFocusable(document.getElementById(id))
|
||||
|
||||
Reference in New Issue
Block a user