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

@@ -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>
);
}
); );

View 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',
},
]);

View File

@@ -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}

View File

@@ -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',
}), }),
[] []
); );

View File

@@ -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,
}); });

View File

@@ -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)',
},
});

View File

@@ -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%;