Compare commits

...

57 Commits

Author SHA1 Message Date
Cyril eaa3a2a284 fixup! ♻️ decouple PiP behavior from shared primitives 2026-05-12 09:40:13 +02:00
Cyril cdc5add2f3 ♻️(refactor) Share reactions toggle and paginated toolbar
Unify main and PiP emoji UI via context; remove PiP-only duplicates.
2026-05-12 09:31:24 +02:00
Cyril 8793226427 (frontend) add PiP placeholder on main window
Show placeholder when PiP is open; i18n and focus-visible on bring-back action.
2026-05-12 09:24:48 +02:00
Cyril 357a989b9d fixup! ♻️ decouple PiP behavior from shared primitives 2026-05-12 09:24:48 +02:00
Cyril b8f3c0aa59 ♻️(refactor) remove PiP subtitles controls and align PiP toast container
Drop subtitles actions from PiP controls and reuse main toast container style.
2026-05-12 09:24:48 +02:00
Cyril 37a3d6e7ed fixup! ♻️(frontend) harden PiP notifications stacking and dedupe 2026-05-12 09:24:47 +02:00
Cyril afa9d36329 fixup! ♻️(refactor) inject side panel store via context for PiP 2026-05-12 09:24:47 +02:00
Cyril 3d81110f56 ♻️(refactor) inject side panel store via context for PiP
Keep pipLayoutStore separate from layoutStore; shared components use context.
2026-05-12 09:24:46 +02:00
Cyril 5a508aeb31 ♻️ decouple PiP behavior from shared primitives
Remove PiP-specific branching from Menu/Tooltip primitives via explicit props and context.
2026-05-12 09:24:46 +02:00
Cyril f9ca489b2f fixup! ♻️(frontend) normalize PiP component patterns 2026-05-12 09:24:46 +02:00
Cyril 47ec78973b ♻️(frontend) clean Sonar warnings in PiP overlay primitives
Replace verbose checks and nested ternaries with clearer PiP-safe logic.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 09:24:45 +02:00
Cyril c239a7f2e5 💄(frontend) adjust PiP stage region horizontal spacing
Use left/right margins only on the PiP stage region container.
2026-05-12 09:24:45 +02:00
Cyril 87f8d4ca7e 🐛(frontend) route PiP tile effects to the PiP side panel
Inject side-panel store into PiP tiles so Effects opens in PiP window.
2026-05-12 09:24:44 +02:00
Cyril 45b939da5c ️(frontend) restore focus-visible rings inside PiP window
Sync React Aria interaction modality with PiP keyboard and mouse events.
2026-05-12 09:24:43 +02:00
Cyril 5a6654b3a8 🐛(frontend) restore participant metadata in PiP tiles
Show participant name and tile metadata in PiP layouts.
2026-05-12 09:24:42 +02:00
Cyril afaaec4be1 🐛(frontend) fix PiP reactions rendering and shortcut ownership
Show reactions in PiP and prevent main shortcuts from overriding PiP.
2026-05-12 09:24:42 +02:00
Cyril 184e957565 ♻️(frontend) fix VisualOnlyTooltip clipping in PiP
Flip when top overflows; clamp left/right so PiP tooltips stay visible.
2026-05-12 09:24:41 +02:00
Cyril 323a998721 ♻️(frontend) address Sonar warnings in PiP and side panel
Replace nested ternary logic, use globalThis instead of window, and switch side-panel enum re-exports to export-from for clearer and safer patterns.

Made-with: Cursor
2026-05-12 09:24:41 +02:00
Cyril 2b50dd3158 ♻️(frontend) normalize PiP component patterns
Extract OverflowItems, replace `any`, convert function declarations to const arrows.
2026-05-12 09:24:41 +02:00
Cyril 852789cec6 ♻️(frontend) harden PiP notifications stacking and dedupe
Group banner and toasts in one stack, cap visibility, fix chat dedup.
2026-05-12 09:24:40 +02:00
Cyril f38dec6a93 (frontend) show room notifications inside the PiP window
Render the shared toast queue in PiP.
2026-05-12 09:24:40 +02:00
Cyril d8548ec9bb (frontend) restore toolbar shortcuts inside PiP window
Forward PiP keydown events and register PiP-specific handlers.
2026-05-12 09:24:39 +02:00
Cyril 775c8ac073 🐛(frontend) close native PiP window when leaving the room
Force-close PiP on unmount and reset PiP state so next room starts clean.
2026-05-12 09:24:38 +02:00
Cyril 90ef4ce025 🐛(frontend) fix PiP tile corner clipping and unify 4px radius
Set a single clipping owner per PiP layout and use a consistent 4px radius.
2026-05-12 09:24:37 +02:00
Cyril 8b1d200740 ♻️(frontend) PiP options menu and reactions toolbar refactor
PiP-native options popover; split reactions, keyboard nav, and pagination.
2026-05-12 09:24:37 +02:00
Cyril f6510b7488 ️(frontend) PiP accessibility and cross-document focus foundation
Cross-doc focus helpers, Escape stack, lang/title sync, landmarks, SR announce.
2026-05-12 09:24:36 +02:00
Cyril 640205fd9e 🌐(i18n) add PiP accessibility strings to rooms
Labels for PiP window, stage, toolbar, reactions, and SR open/close.
2026-05-12 09:24:36 +02:00
Cyril 3c04d1e9a7 🎨(frontend) tune PiP window default dimensions
Start PiP in a taller shape closer to a typical video call layout.
2026-05-12 09:24:35 +02:00
Cyril bbc3d18309 (frontend) add adaptive PiP stage with focus and grid layouts
Render all participants with a focus mode and animated adaptive grid.
2026-05-12 09:24:35 +02:00
Cyril 61877d23bc ♻️(frontend) share a PiP-aware element size hook
Reuse a single ResizeObserver hook in the PiP control bar.
2026-05-12 09:24:34 +02:00
Cyril a6d20e2f58 (frontend) add PiP reactions toolbar and isolated state
PiP-only reactions strip, store flag, and overflow menu wired to PiP state.
2026-05-12 09:24:34 +02:00
Cyril 1e21274237 🐛(frontend) fix pip controls responsive overflow in realtime
Use PiP document ResizeObserver so controls hide into "..." while resizing.
2026-05-12 09:24:34 +02:00
Cyril 6b566831a2 🐛(frontend) align pip layout with google meet
Show a main tile with a bottom-right thumbnail in PiP, like Google Meet.
2026-05-12 09:24:32 +02:00
Cyril b57e11bf51 ♻️(frontend) decouple pip state and simplify pip options
Prevent PiP actions from affecting main panel state and reduce PiP-only noise.
2026-05-12 09:24:31 +02:00
Cyril 2378af9e9b (perf) hide main layout when pip is open
avoid rendering main video tiles while pip is active
2026-05-12 09:24:31 +02:00
Cyril 30107882dd ♻️(refactor) move pip feature into dedicated module
group pip components and hooks under features/pip for isolation
2026-05-12 09:24:30 +02:00
Cyril 4f25a1d983 (feat) move pip state to valtio store
replace context provider with shared store for pip ui state
2026-05-12 09:24:30 +02:00
Cyril 2c02a26563 🐛(frontend) clamp pip tooltips on right edge
prevent pip tooltips from overflowing the right side of the window
2026-05-12 09:24:29 +02:00
Cyril 3eb694b6dd fixup! 🌐(i18n) add PiP labels for each supported language 2026-05-12 09:24:29 +02:00
Cyril 9b2e7905fc fixup! (feat) connect PiP to room and options menu 2026-05-12 09:24:29 +02:00
Cyril e1c6fb10f3 fixup! 🐛(fix) add react-aria overlays type shim 2026-05-12 09:24:28 +02:00
Cyril 04ceb5d568 (feat) connect PiP to room and options menu
wire room-level PiP state to the options menu
2026-05-12 09:24:28 +02:00
Cyril 1771527c2d 💄(style) align pip layout with room styling
match pip background and spacing to
2026-05-12 09:24:27 +02:00
Cyril 63a949e420 💄(style) adjust tooltip positioning and fix ts error
raise tooltip offset and set arrowBoundaryOffset
2026-05-12 09:24:26 +02:00
Cyril 69135feba5 🐛(fix) add react-aria overlays type shim
fix missing useUNSAFE_PortalContext export typing
2026-05-12 09:24:26 +02:00
Cyril 57a27de8e7 (feat) add pip lateral menu component
add pip chevron menu
2026-05-12 09:24:25 +02:00
Cyril 09bb4daebc 📝(docs) add pip component documentation
add jsdoc comments to pip components and hooks
2026-05-12 09:24:25 +02:00
Cyril 3e3d9f31a5 🐛(fix) close pip menu on item action
close options menu when selecting transcription or
2026-05-12 09:24:24 +02:00
Cyril 439f7b3746 ♻️(refactor) extract pip control bar component
centralize pip controls in reusable component
2026-05-12 09:24:23 +02:00
Cyril fa6967635d ♻️(feat) extract pip options menu component
keep pip options layout reusable and isolated
2026-05-12 09:24:23 +02:00
Cyril 07e172041f 💄(fix) align overlays in pip
route menus/tooltips to the pip document
2026-05-12 09:24:22 +02:00
Cyril 8c93d26d48 (refactor) stabilize pip layout and controls
avoid layout jumps and prepare for more buttons
2026-05-12 09:24:22 +02:00
Cyril a2c55f2305 🐛(fix) rebuild pip root on reopen
prevent black screen after closing and reopening pip
2026-05-12 09:24:22 +02:00
Cyril 2f1d102b44 🌐(i18n) add PiP labels for each supported language
add labels for PiP in each supported language
2026-05-12 09:24:21 +02:00
Cyril e9634b7610 (feat) connect PiP to room and options menu
connecting pip and room
2026-05-12 09:24:20 +02:00
Cyril 4697cbc875 (feat) add pip portal and base layout
mount react content into pip window with styles
2026-05-12 09:24:20 +02:00
Cyril 8c3fb63724 (feat) add document pip hook
utility hook to cleanly open/close the pip window
2026-05-12 09:24:14 +02:00
57 changed files with 2883 additions and 272 deletions
+1
View File
@@ -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
View File
@@ -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;
}
+121
View File
@@ -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')}
@@ -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 />
@@ -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 />
@@ -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])
}
+22
View File
@@ -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"
+22
View File
@@ -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"
+22
View File
@@ -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"
+22
View File
@@ -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"
+7 -2
View File
@@ -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>
+5 -1
View File
@@ -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">
+30 -10
View File
@@ -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 -4
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
isOpen: boolean
}
export const roomPiPStore = proxy<State>({
isOpen: false,
})
+19 -5
View File
@@ -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))