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 { Box, IconButton, Icon, Icons, Tooltip, TooltipProvider, Text } from 'folds';
|
||||
import * as css from './media.css';
|
||||
import * as videoCss from './videoControls.css';
|
||||
|
||||
export const Video = forwardRef<HTMLVideoElement, VideoHTMLAttributes<HTMLVideoElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
type VideoProps = VideoHTMLAttributes<HTMLVideoElement> & {
|
||||
disableControls?: boolean;
|
||||
};
|
||||
|
||||
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={ref} />
|
||||
)
|
||||
<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
|
||||
*/
|
||||
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;
|
||||
}
|
||||
} catch (err) {
|
||||
// Fullscreen may not be available
|
||||
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);
|
||||
}
|
||||
|
||||
// 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}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { lightTheme } from 'folds';
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
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 { useSetting } from '../state/hooks/settings';
|
||||
|
||||
@@ -37,9 +37,19 @@ export const ButterTheme: Theme = {
|
||||
kind: ThemeKind.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[] => {
|
||||
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme], []);
|
||||
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme, DiscordTheme, DiscordDarkerTheme], []);
|
||||
|
||||
return themes;
|
||||
};
|
||||
@@ -51,6 +61,8 @@ export const useThemeNames = (): Record<string, string> =>
|
||||
[SilverTheme.id]: 'Silver',
|
||||
[DarkTheme.id]: 'Dark',
|
||||
[ButterTheme.id]: 'Butter',
|
||||
[DiscordTheme.id]: 'Discord',
|
||||
[DiscordDarkerTheme.id]: 'Discord Darker',
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
@@ -206,6 +206,18 @@ function MessageNotifications() {
|
||||
? getDirectRoomPath(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()) {
|
||||
sendNotification({
|
||||
title: roomName,
|
||||
@@ -215,11 +227,29 @@ function MessageNotifications() {
|
||||
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 {
|
||||
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,
|
||||
badge: roomAvatar,
|
||||
body,
|
||||
body: notificationBody,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -236,3 +236,197 @@ export const butterTheme = createTheme(color, {
|
||||
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,
|
||||
.butter-theme {
|
||||
.butter-theme,
|
||||
.discord-theme,
|
||||
.discord-darker-theme {
|
||||
--tc-link: hsl(213deg 100% 80%);
|
||||
|
||||
--mx-uc-1: hsl(208, 100%, 75%);
|
||||
@@ -103,6 +105,12 @@ body.dark-theme {
|
||||
body.butter-theme {
|
||||
background-color: #1A1916;
|
||||
}
|
||||
body.discord-theme {
|
||||
background-color: #2B2D31;
|
||||
}
|
||||
body.discord-darker-theme {
|
||||
background-color: #1E1F22;
|
||||
}
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
Reference in New Issue
Block a user