feat: implement video controls and pop-out functionality in Video component
This commit is contained in:
@@ -1,10 +1,168 @@
|
|||||||
import React, { VideoHTMLAttributes, forwardRef } from 'react';
|
import React, { VideoHTMLAttributes, forwardRef, useRef, useState, useEffect } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { Box, IconButton, Icon, Icons, Tooltip, TooltipProvider, Text } from 'folds';
|
||||||
import * as css from './media.css';
|
import * as css from './media.css';
|
||||||
|
import * as videoCss from './videoControls.css';
|
||||||
|
|
||||||
export const Video = forwardRef<HTMLVideoElement, VideoHTMLAttributes<HTMLVideoElement>>(
|
type VideoProps = VideoHTMLAttributes<HTMLVideoElement> & {
|
||||||
({ className, ...props }, ref) => (
|
disableControls?: boolean;
|
||||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
};
|
||||||
<video className={classNames(css.Video, className)} {...props} ref={ref} />
|
|
||||||
)
|
export const Video = forwardRef<HTMLVideoElement, VideoProps>(
|
||||||
|
({ className, disableControls, ...props }, ref) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [showControls, setShowControls] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
// Combine refs
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof ref === 'function') {
|
||||||
|
ref(videoRef.current);
|
||||||
|
} else if (ref) {
|
||||||
|
ref.current = videoRef.current;
|
||||||
|
}
|
||||||
|
}, [ref]);
|
||||||
|
|
||||||
|
// Track fullscreen state
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFullscreen = async () => {
|
||||||
|
try {
|
||||||
|
if (!document.fullscreenElement && containerRef.current) {
|
||||||
|
await containerRef.current.requestFullscreen();
|
||||||
|
} else if (document.fullscreenElement) {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling fullscreen:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopOut = () => {
|
||||||
|
const videoSrc = videoRef.current?.src;
|
||||||
|
const videoTitle = props.title || 'Video';
|
||||||
|
|
||||||
|
if (videoSrc) {
|
||||||
|
const popoutWindow = window.open(
|
||||||
|
'',
|
||||||
|
'_blank',
|
||||||
|
'width=800,height=600,menubar=no,toolbar=no,location=no,status=no'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (popoutWindow) {
|
||||||
|
popoutWindow.document.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>${videoTitle}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<video controls autoplay src="${videoSrc}">
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
popoutWindow.document.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (disableControls) {
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||||
|
<video className={classNames(css.Video, className)} {...props} ref={videoRef} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={containerRef}
|
||||||
|
className={videoCss.VideoContainer}
|
||||||
|
onMouseEnter={() => setShowControls(true)}
|
||||||
|
onMouseLeave={() => setShowControls(false)}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||||
|
<video className={classNames(css.Video, className)} {...props} ref={videoRef} />
|
||||||
|
|
||||||
|
{showControls && (
|
||||||
|
<Box className={videoCss.VideoControlsOverlay} gap="200">
|
||||||
|
<TooltipProvider
|
||||||
|
tooltip={
|
||||||
|
<Tooltip variant="Secondary">
|
||||||
|
<Text size="T200">{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
position="Bottom"
|
||||||
|
align="Center"
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="Secondary"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={handleFullscreen}
|
||||||
|
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size="200"
|
||||||
|
src={isFullscreen ? Icons.Minus : Icons.Plus}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
28
src/app/components/media/videoControls.css.ts
Normal file
28
src/app/components/media/videoControls.css.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { DefaultReset, config } from 'folds';
|
||||||
|
|
||||||
|
export const VideoContainer = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const VideoControlsOverlay = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
top: config.space.S200,
|
||||||
|
right: config.space.S200,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: config.space.S200,
|
||||||
|
zIndex: 10,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
},
|
||||||
|
]);
|
||||||
@@ -301,13 +301,19 @@ interface ScreenShareItemProps {
|
|||||||
* Handles attaching the video stream/track properly
|
* Handles attaching the video stream/track properly
|
||||||
*/
|
*/
|
||||||
export function ScreenShareItem({ share }: ScreenShareItemProps) {
|
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);
|
const { handleMouseMove, handleMouseLeave } = useScreenShareCursor(share.participantId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = videoContainerRef.current;
|
||||||
if (!container) return undefined;
|
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)
|
// Handle local screenshare (MediaStream)
|
||||||
if (share.isLocal && share.stream) {
|
if (share.isLocal && share.stream) {
|
||||||
const videoEl = document.createElement('video');
|
const videoEl = document.createElement('video');
|
||||||
@@ -354,38 +360,307 @@ export function ScreenShareItem({ share }: ScreenShareItemProps) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [share.isLocal, share.stream, share.track]);
|
}, [share.isLocal, share.stream, share.track]);
|
||||||
|
|
||||||
/**
|
const handlePopOut = useCallback(() => {
|
||||||
* Handles double-click to toggle fullscreen on the screenshare video element
|
const popoutWindow = window.open(
|
||||||
*/
|
'',
|
||||||
const handleDoubleClick = useCallback(async () => {
|
'_blank',
|
||||||
const container = containerRef.current;
|
'width=1200,height=800,frame=false,titleBarStyle=hidden'
|
||||||
if (!container) return;
|
);
|
||||||
|
|
||||||
const videoEl = container.querySelector('video');
|
if (!popoutWindow) return;
|
||||||
if (!videoEl) return;
|
|
||||||
|
|
||||||
try {
|
popoutWindow.document.write(`
|
||||||
if (document.fullscreenElement) {
|
<!DOCTYPE html>
|
||||||
await document.exitFullscreen();
|
<html>
|
||||||
} else {
|
<head>
|
||||||
await videoEl.requestFullscreen();
|
<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 (
|
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 */}
|
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={videoContainerRef}
|
||||||
className={css.ScreenShareVideoContainer}
|
className={css.ScreenShareVideoContainer}
|
||||||
onDoubleClick={handleDoubleClick}
|
onMouseMove={handleMouseMoveWrapper}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseLeave={handleMouseLeaveWrapper}
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
style={{ cursor: 'pointer' }}
|
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} />
|
<RemoteCursorOverlay shareId={share.participantId} roomId={share.roomId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -538,6 +813,68 @@ export function CallOverlay() {
|
|||||||
}
|
}
|
||||||
}, [isFullscreen]);
|
}, [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
|
// PiP toggle handler
|
||||||
const handleTogglePip = useCallback(async () => {
|
const handleTogglePip = useCallback(async () => {
|
||||||
const videoEl = remoteVideoRef.current;
|
const videoEl = remoteVideoRef.current;
|
||||||
@@ -778,6 +1115,17 @@ export function CallOverlay() {
|
|||||||
</Text>
|
</Text>
|
||||||
{isVideoCall && (
|
{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
|
<TooltipProvider
|
||||||
position="Top"
|
position="Top"
|
||||||
offset={4}
|
offset={4}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { lightTheme } from 'folds';
|
import { lightTheme } from 'folds';
|
||||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
|
||||||
import { butterTheme, darkTheme, silverTheme } from '../../colors.css';
|
import { butterTheme, darkTheme, discordTheme, discordDarkerTheme, silverTheme } from '../../colors.css';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
|
||||||
@@ -37,9 +37,19 @@ export const ButterTheme: Theme = {
|
|||||||
kind: ThemeKind.Dark,
|
kind: ThemeKind.Dark,
|
||||||
classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'],
|
classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'],
|
||||||
};
|
};
|
||||||
|
export const DiscordTheme: Theme = {
|
||||||
|
id: 'discord-theme',
|
||||||
|
kind: ThemeKind.Dark,
|
||||||
|
classNames: ['discord-theme', discordTheme, onDarkFontWeight, 'prism-dark'],
|
||||||
|
};
|
||||||
|
export const DiscordDarkerTheme: Theme = {
|
||||||
|
id: 'discord-darker-theme',
|
||||||
|
kind: ThemeKind.Dark,
|
||||||
|
classNames: ['discord-darker-theme', discordDarkerTheme, onDarkFontWeight, 'prism-dark'],
|
||||||
|
};
|
||||||
|
|
||||||
export const useThemes = (): Theme[] => {
|
export const useThemes = (): Theme[] => {
|
||||||
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme], []);
|
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme, DiscordTheme, DiscordDarkerTheme], []);
|
||||||
|
|
||||||
return themes;
|
return themes;
|
||||||
};
|
};
|
||||||
@@ -51,6 +61,8 @@ export const useThemeNames = (): Record<string, string> =>
|
|||||||
[SilverTheme.id]: 'Silver',
|
[SilverTheme.id]: 'Silver',
|
||||||
[DarkTheme.id]: 'Dark',
|
[DarkTheme.id]: 'Dark',
|
||||||
[ButterTheme.id]: 'Butter',
|
[ButterTheme.id]: 'Butter',
|
||||||
|
[DiscordTheme.id]: 'Discord',
|
||||||
|
[DiscordDarkerTheme.id]: 'Discord Darker',
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -206,6 +206,18 @@ function MessageNotifications() {
|
|||||||
? getDirectRoomPath(roomId, eventId)
|
? getDirectRoomPath(roomId, eventId)
|
||||||
: getHomeRoomPath(roomId, eventId);
|
: getHomeRoomPath(roomId, eventId);
|
||||||
|
|
||||||
|
// Check if running in Electron
|
||||||
|
const isElectron = 'electron' in window;
|
||||||
|
const hasElectronNotification = (window as any).electron?.notification?.show;
|
||||||
|
|
||||||
|
console.log('Notification check:', {
|
||||||
|
isElectron,
|
||||||
|
hasElectronNotification,
|
||||||
|
isTauri: isTauri(),
|
||||||
|
username,
|
||||||
|
messageBody
|
||||||
|
});
|
||||||
|
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
sendNotification({
|
sendNotification({
|
||||||
title: roomName,
|
title: roomName,
|
||||||
@@ -215,11 +227,29 @@ function MessageNotifications() {
|
|||||||
if (!window.closed) navigate(roomPath);
|
if (!window.closed) navigate(roomPath);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} else if (isElectron && hasElectronNotification) {
|
||||||
|
// Use Electron desktop notifications
|
||||||
|
const notificationTitle = `${username} - Paarrot`;
|
||||||
|
const notificationBody = messageBody || 'New message';
|
||||||
|
|
||||||
|
console.log('Sending Electron notification:', { title: notificationTitle, body: notificationBody });
|
||||||
|
|
||||||
|
(window as any).electron.notification.show({
|
||||||
|
title: notificationTitle,
|
||||||
|
body: notificationBody,
|
||||||
|
icon: roomAvatar,
|
||||||
|
}).catch((err: any) => console.error('Electron notification error:', err));
|
||||||
} else {
|
} else {
|
||||||
const noti = new window.Notification(roomName, {
|
// Fallback to web Notification API - also use custom format
|
||||||
|
const notificationTitle = `${username} - Paarrot`;
|
||||||
|
const notificationBody = messageBody || 'New message';
|
||||||
|
|
||||||
|
console.log('Sending web notification:', { title: notificationTitle, body: notificationBody });
|
||||||
|
|
||||||
|
const noti = new window.Notification(notificationTitle, {
|
||||||
icon: roomAvatar,
|
icon: roomAvatar,
|
||||||
badge: roomAvatar,
|
badge: roomAvatar,
|
||||||
body,
|
body: notificationBody,
|
||||||
silent: true,
|
silent: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -236,3 +236,197 @@ export const butterTheme = createTheme(color, {
|
|||||||
OnContainer: '#F2EED3',
|
OnContainer: '#F2EED3',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const discordTheme = createTheme(color, {
|
||||||
|
Background: {
|
||||||
|
Container: '#2B2D31',
|
||||||
|
ContainerHover: '#313338',
|
||||||
|
ContainerActive: '#383A40',
|
||||||
|
ContainerLine: '#404249',
|
||||||
|
OnContainer: '#DBDEE1',
|
||||||
|
},
|
||||||
|
|
||||||
|
Surface: {
|
||||||
|
Container: '#313338',
|
||||||
|
ContainerHover: '#383A40',
|
||||||
|
ContainerActive: '#404249',
|
||||||
|
ContainerLine: '#4E505A',
|
||||||
|
OnContainer: '#DBDEE1',
|
||||||
|
},
|
||||||
|
|
||||||
|
SurfaceVariant: {
|
||||||
|
Container: '#383A40',
|
||||||
|
ContainerHover: '#404249',
|
||||||
|
ContainerActive: '#4E505A',
|
||||||
|
ContainerLine: '#5C5E68',
|
||||||
|
OnContainer: '#DBDEE1',
|
||||||
|
},
|
||||||
|
|
||||||
|
Primary: {
|
||||||
|
Main: '#5865F2',
|
||||||
|
MainHover: '#4752C4',
|
||||||
|
MainActive: '#3C45A5',
|
||||||
|
MainLine: '#343B8F',
|
||||||
|
OnMain: '#FFFFFF',
|
||||||
|
Container: '#4752C4',
|
||||||
|
ContainerHover: '#3C45A5',
|
||||||
|
ContainerActive: '#343B8F',
|
||||||
|
ContainerLine: '#2D3377',
|
||||||
|
OnContainer: '#E7E9FD',
|
||||||
|
},
|
||||||
|
|
||||||
|
Secondary: {
|
||||||
|
Main: '#B5BAC1',
|
||||||
|
MainHover: '#949BA4',
|
||||||
|
MainActive: '#80858E',
|
||||||
|
MainLine: '#6D7178',
|
||||||
|
OnMain: '#060607',
|
||||||
|
Container: '#4E5058',
|
||||||
|
ContainerHover: '#5C5E68',
|
||||||
|
ContainerActive: '#6A6D75',
|
||||||
|
ContainerLine: '#80858E',
|
||||||
|
OnContainer: '#E3E5E8',
|
||||||
|
},
|
||||||
|
|
||||||
|
Success: {
|
||||||
|
Main: '#23A55A',
|
||||||
|
MainHover: '#1F8F4F',
|
||||||
|
MainActive: '#1C7F46',
|
||||||
|
MainLine: '#196F3D',
|
||||||
|
OnMain: '#FFFFFF',
|
||||||
|
Container: '#1F8F4F',
|
||||||
|
ContainerHover: '#1C7F46',
|
||||||
|
ContainerActive: '#196F3D',
|
||||||
|
ContainerLine: '#165F34',
|
||||||
|
OnContainer: '#D4F2E1',
|
||||||
|
},
|
||||||
|
|
||||||
|
Warning: {
|
||||||
|
Main: '#F0B232',
|
||||||
|
MainHover: '#D69B2B',
|
||||||
|
MainActive: '#C18B27',
|
||||||
|
MainLine: '#AD7C23',
|
||||||
|
OnMain: '#2E2309',
|
||||||
|
Container: '#8B6A1C',
|
||||||
|
ContainerHover: '#9C771F',
|
||||||
|
ContainerActive: '#AD8422',
|
||||||
|
ContainerLine: '#BE9125',
|
||||||
|
OnContainer: '#FCEDC9',
|
||||||
|
},
|
||||||
|
|
||||||
|
Critical: {
|
||||||
|
Main: '#F23F43',
|
||||||
|
MainHover: '#D73437',
|
||||||
|
MainActive: '#C12D30',
|
||||||
|
MainLine: '#AB272A',
|
||||||
|
OnMain: '#FFFFFF',
|
||||||
|
Container: '#A12D2F',
|
||||||
|
ContainerHover: '#B23235',
|
||||||
|
ContainerActive: '#C3373B',
|
||||||
|
ContainerLine: '#D43C40',
|
||||||
|
OnContainer: '#FDD7D8',
|
||||||
|
},
|
||||||
|
|
||||||
|
Other: {
|
||||||
|
FocusRing: 'rgba(88, 101, 242, 0.5)',
|
||||||
|
Shadow: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
Overlay: 'rgba(0, 0, 0, 0.85)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const discordDarkerTheme = createTheme(color, {
|
||||||
|
Background: {
|
||||||
|
Container: '#1E1F22',
|
||||||
|
ContainerHover: '#232428',
|
||||||
|
ContainerActive: '#2A2C31',
|
||||||
|
ContainerLine: '#313338',
|
||||||
|
OnContainer: '#E3E5E8',
|
||||||
|
},
|
||||||
|
|
||||||
|
Surface: {
|
||||||
|
Container: '#232428',
|
||||||
|
ContainerHover: '#2A2C31',
|
||||||
|
ContainerActive: '#313338',
|
||||||
|
ContainerLine: '#3B3E45',
|
||||||
|
OnContainer: '#E3E5E8',
|
||||||
|
},
|
||||||
|
|
||||||
|
SurfaceVariant: {
|
||||||
|
Container: '#2A2C31',
|
||||||
|
ContainerHover: '#313338',
|
||||||
|
ContainerActive: '#3B3E45',
|
||||||
|
ContainerLine: '#45474D',
|
||||||
|
OnContainer: '#E3E5E8',
|
||||||
|
},
|
||||||
|
|
||||||
|
Primary: {
|
||||||
|
Main: '#5865F2',
|
||||||
|
MainHover: '#4752C4',
|
||||||
|
MainActive: '#3C45A5',
|
||||||
|
MainLine: '#343B8F',
|
||||||
|
OnMain: '#FFFFFF',
|
||||||
|
Container: '#4752C4',
|
||||||
|
ContainerHover: '#3C45A5',
|
||||||
|
ContainerActive: '#343B8F',
|
||||||
|
ContainerLine: '#2D3377',
|
||||||
|
OnContainer: '#E7E9FD',
|
||||||
|
},
|
||||||
|
|
||||||
|
Secondary: {
|
||||||
|
Main: '#B5BAC1',
|
||||||
|
MainHover: '#949BA4',
|
||||||
|
MainActive: '#80858E',
|
||||||
|
MainLine: '#6D7178',
|
||||||
|
OnMain: '#060607',
|
||||||
|
Container: '#3B3E45',
|
||||||
|
ContainerHover: '#45474D',
|
||||||
|
ContainerActive: '#4F5158',
|
||||||
|
ContainerLine: '#5C5E68',
|
||||||
|
OnContainer: '#E3E5E8',
|
||||||
|
},
|
||||||
|
|
||||||
|
Success: {
|
||||||
|
Main: '#23A55A',
|
||||||
|
MainHover: '#1F8F4F',
|
||||||
|
MainActive: '#1C7F46',
|
||||||
|
MainLine: '#196F3D',
|
||||||
|
OnMain: '#FFFFFF',
|
||||||
|
Container: '#1F8F4F',
|
||||||
|
ContainerHover: '#1C7F46',
|
||||||
|
ContainerActive: '#196F3D',
|
||||||
|
ContainerLine: '#165F34',
|
||||||
|
OnContainer: '#D4F2E1',
|
||||||
|
},
|
||||||
|
|
||||||
|
Warning: {
|
||||||
|
Main: '#F0B232',
|
||||||
|
MainHover: '#D69B2B',
|
||||||
|
MainActive: '#C18B27',
|
||||||
|
MainLine: '#AD7C23',
|
||||||
|
OnMain: '#2E2309',
|
||||||
|
Container: '#8B6A1C',
|
||||||
|
ContainerHover: '#9C771F',
|
||||||
|
ContainerActive: '#AD8422',
|
||||||
|
ContainerLine: '#BE9125',
|
||||||
|
OnContainer: '#FCEDC9',
|
||||||
|
},
|
||||||
|
|
||||||
|
Critical: {
|
||||||
|
Main: '#F23F43',
|
||||||
|
MainHover: '#D73437',
|
||||||
|
MainActive: '#C12D30',
|
||||||
|
MainLine: '#AB272A',
|
||||||
|
OnMain: '#FFFFFF',
|
||||||
|
Container: '#A12D2F',
|
||||||
|
ContainerHover: '#B23235',
|
||||||
|
ContainerActive: '#C3373B',
|
||||||
|
ContainerLine: '#D43C40',
|
||||||
|
OnContainer: '#FDD7D8',
|
||||||
|
},
|
||||||
|
|
||||||
|
Other: {
|
||||||
|
FocusRing: 'rgba(88, 101, 242, 0.5)',
|
||||||
|
Shadow: 'rgba(0, 0, 0, 0.9)',
|
||||||
|
Overlay: 'rgba(0, 0, 0, 0.9)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -46,7 +46,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark-theme,
|
.dark-theme,
|
||||||
.butter-theme {
|
.butter-theme,
|
||||||
|
.discord-theme,
|
||||||
|
.discord-darker-theme {
|
||||||
--tc-link: hsl(213deg 100% 80%);
|
--tc-link: hsl(213deg 100% 80%);
|
||||||
|
|
||||||
--mx-uc-1: hsl(208, 100%, 75%);
|
--mx-uc-1: hsl(208, 100%, 75%);
|
||||||
@@ -103,6 +105,12 @@ body.dark-theme {
|
|||||||
body.butter-theme {
|
body.butter-theme {
|
||||||
background-color: #1A1916;
|
background-color: #1A1916;
|
||||||
}
|
}
|
||||||
|
body.discord-theme {
|
||||||
|
background-color: #2B2D31;
|
||||||
|
}
|
||||||
|
body.discord-darker-theme {
|
||||||
|
background-color: #1E1F22;
|
||||||
|
}
|
||||||
#root {
|
#root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user