feat: implement video controls and pop-out functionality in Video component

This commit is contained in:
2026-02-27 22:12:47 +11:00
parent 58966aec19
commit f8af51b75c
7 changed files with 811 additions and 33 deletions

View File

@@ -301,13 +301,19 @@ interface ScreenShareItemProps {
* Handles attaching the video stream/track properly
*/
export function ScreenShareItem({ share }: ScreenShareItemProps) {
const containerRef = useRef<HTMLDivElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null);
const [showControls, setShowControls] = useState(false);
const [isPoppedOut, setIsPoppedOut] = useState(false);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { handleMouseMove, handleMouseLeave } = useScreenShareCursor(share.participantId);
useEffect(() => {
const container = containerRef.current;
const container = videoContainerRef.current;
if (!container) return undefined;
// Always render video in main window (even when popped out - just hidden)
// This keeps the track alive
// Handle local screenshare (MediaStream)
if (share.isLocal && share.stream) {
const videoEl = document.createElement('video');
@@ -354,38 +360,307 @@ export function ScreenShareItem({ share }: ScreenShareItemProps) {
return undefined;
}, [share.isLocal, share.stream, share.track]);
/**
* Handles double-click to toggle fullscreen on the screenshare video element
*/
const handleDoubleClick = useCallback(async () => {
const container = containerRef.current;
if (!container) return;
const handlePopOut = useCallback(() => {
const popoutWindow = window.open(
'',
'_blank',
'width=1200,height=800,frame=false,titleBarStyle=hidden'
);
const videoEl = container.querySelector('video');
if (!videoEl) return;
if (!popoutWindow) return;
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await videoEl.requestFullscreen();
popoutWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>Screen Share</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
user-select: none;
position: relative;
}
#videoContainer {
width: 100%;
height: 100%;
}
#videoContainer video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
-webkit-app-region: drag;
cursor: move;
}
#closeButton {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
background: rgba(0, 0, 0, 0.7);
border: none;
border-radius: 8px;
color: #fff;
font-size: 20px;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
-webkit-app-region: no-drag;
transition: background 0.2s;
}
#closeButton:hover {
background: rgba(255, 0, 0, 0.8);
}
#closeButton.visible {
display: flex;
}
</style>
</head>
<body>
<div id="videoContainer"></div>
<button id="closeButton" title="Close">×</button>
<script>
let hideTimeout = null;
const closeButton = document.getElementById('closeButton');
function showCloseButton() {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
closeButton.classList.add('visible');
hideTimeout = setTimeout(() => {
closeButton.classList.remove('visible');
hideTimeout = null;
}, 2000);
}
// Show on any mouse movement
document.addEventListener('mousemove', showCloseButton, true);
// Keep visible when hovering over button
closeButton.addEventListener('mouseenter', () => {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
closeButton.classList.add('visible');
});
// Start hide timer when leaving button
closeButton.addEventListener('mouseleave', () => {
hideTimeout = setTimeout(() => {
closeButton.classList.remove('visible');
hideTimeout = null;
}, 2000);
});
// Close window on click
closeButton.addEventListener('click', () => {
window.close();
});
// Keep video playing - prevent pause from visibility changes or focus loss
function keepVideoPlaying() {
const video = document.querySelector('video');
if (video && video.paused && video.srcObject) {
video.play().catch(() => {});
}
}
// Check every 500ms if video got paused
setInterval(keepVideoPlaying, 500);
// Also resume on visibility change
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
keepVideoPlaying();
}
});
// Resume on window focus
window.addEventListener('focus', keepVideoPlaying);
</script>
</body>
</html>
`);
popoutWindow.document.close();
// Wait for document to be ready, attach video FIRST (while still attached to main), THEN hide main
setTimeout(() => {
const videoContainer = popoutWindow.document.getElementById('videoContainer');
if (!videoContainer) return;
// For local screenshare, create new video with the MediaStream
if (share.isLocal && share.stream) {
const videoEl = popoutWindow.document.createElement('video');
videoEl.autoplay = true;
videoEl.playsInline = true;
videoEl.muted = true;
videoEl.srcObject = share.stream;
videoContainer.appendChild(videoEl);
videoEl.play().catch(() => {});
// Now that pop-out has the stream, hide main window
setIsPoppedOut(true);
}
} catch (err) {
// Fullscreen may not be available
// For remote screenshare, use LiveKit track's attach method (creates fresh video)
if (!share.isLocal && share.track) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const track = share.track as any;
if (typeof track.attach === 'function') {
// track.attach() creates a brand new video element attached to the track
// Track is now attached to BOTH main window and pop-out
const videoEl = track.attach() as HTMLVideoElement;
videoEl.style.width = '100%';
videoEl.style.height = '100%';
videoEl.style.objectFit = 'contain';
videoEl.style.display = 'block';
videoEl.style.cursor = 'move';
// @ts-expect-error webkit property
videoEl.style.webkitAppRegion = 'drag';
videoContainer.appendChild(videoEl);
// Now that pop-out has the track, hide main window (which will detach from main)
setIsPoppedOut(true);
}
}
}, 100);
// Show preview again when pop-out window is closed
popoutWindow.addEventListener('beforeunload', () => {
// For remote tracks, detach from pop-out
if (!share.isLocal && share.track) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const track = share.track as any;
if (typeof track.detach === 'function') {
const videoContainer = popoutWindow.document.getElementById('videoContainer');
if (videoContainer) {
const videoEl = videoContainer.querySelector('video');
if (videoEl) {
track.detach(videoEl);
}
}
}
}
setIsPoppedOut(false);
});
}, [share.isLocal, share.stream, share.track]);
const handleMouseMoveWrapper = useCallback((e: React.MouseEvent) => {
// Clear any pending hide timeout
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
setShowControls(true);
handleMouseMove(e);
}, [handleMouseMove]);
const handleMouseLeaveWrapper = useCallback((e: React.MouseEvent) => {
// Delay hiding controls to prevent flickering
hideTimeoutRef.current = setTimeout(() => {
setShowControls(false);
}, 300);
handleMouseLeave(e);
}, [handleMouseLeave]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
};
}, []);
return (
<div className={css.ScreenShareContainer} style={{ position: 'relative' }}>
<div
className={css.ScreenShareContainer}
style={{
position: isPoppedOut ? 'absolute' : 'relative',
width: isPoppedOut ? '1px' : undefined,
height: isPoppedOut ? '1px' : undefined,
opacity: isPoppedOut ? 0 : 1,
pointerEvents: isPoppedOut ? 'none' : 'auto',
overflow: isPoppedOut ? 'hidden' : 'visible',
left: isPoppedOut ? '-9999px' : undefined,
}}
>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
ref={containerRef}
ref={videoContainerRef}
className={css.ScreenShareVideoContainer}
onDoubleClick={handleDoubleClick}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onMouseMove={handleMouseMoveWrapper}
onMouseLeave={handleMouseLeaveWrapper}
style={{ cursor: 'pointer' }}
/>
{/* Control buttons overlay */}
{showControls && (
<Box
style={{
position: 'absolute',
top: '12px',
right: '12px',
display: 'flex',
gap: '8px',
zIndex: 10,
pointerEvents: 'auto',
}}
onMouseEnter={() => {
// Keep controls visible when hovering over them
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
setShowControls(true);
}}
onMouseLeave={() => {
// Hide controls after a delay when leaving the buttons
hideTimeoutRef.current = setTimeout(() => {
setShowControls(false);
}, 300);
}}
>
<TooltipProvider
tooltip={
<Tooltip variant="Secondary">
<Text size="T200">Pop Out</Text>
</Tooltip>
}
position="Bottom"
align="Center"
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Secondary"
size="300"
radii="300"
onClick={handlePopOut}
aria-label="Open in new window"
>
<Icon size="200" src={Icons.External} />
</IconButton>
)}
</TooltipProvider>
</Box>
)}
<RemoteCursorOverlay shareId={share.participantId} roomId={share.roomId} />
</div>
);
@@ -538,6 +813,68 @@ export function CallOverlay() {
}
}, [isFullscreen]);
// Pop-out window handler
const handlePopOut = useCallback(() => {
if (!activeCall) return;
// Get room inside callback to avoid initialization order issues
const callRoom = mx.getRoom(activeCall.roomId);
const popoutWindow = window.open(
'',
'_blank',
'width=1200,height=800,menubar=no,toolbar=no,location=no,status=no'
);
if (popoutWindow && activeCall.remoteStreams.size > 0) {
const [firstStream] = activeCall.remoteStreams.values();
popoutWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>Video Call - ${callRoom?.name ?? 'Call'}</title>
<style>
body {
margin: 0;
padding: 0;
background: #000;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
overflow: hidden;
}
video {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
object-fit: contain;
}
</style>
</head>
<body>
<video id="remoteVideo" autoplay playsinline></video>
<script>
const video = document.getElementById('remoteVideo');
// Stream will be set from parent window
</script>
</body>
</html>
`);
popoutWindow.document.close();
// Wait for window to load, then set the video stream
setTimeout(() => {
const videoEl = popoutWindow.document.getElementById('remoteVideo');
if (videoEl) {
videoEl.srcObject = firstStream;
}
}, 100);
}
}, [activeCall, mx]);
// PiP toggle handler
const handleTogglePip = useCallback(async () => {
const videoEl = remoteVideoRef.current;
@@ -778,6 +1115,17 @@ export function CallOverlay() {
</Text>
{isVideoCall && (
<>
<TooltipProvider
position="Top"
offset={4}
tooltip={<Tooltip><Text>Pop Out</Text></Tooltip>}
>
{(triggerRef) => (
<IconButton ref={triggerRef} onClick={handlePopOut} variant="Secondary" size="300">
<Icon size="200" src={Icons.External} />
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Top"
offset={4}