Add screensaver feature for wall-mounted displays - Create Screensaver component with HTML5 Canvas animation - Implement gradient patterns, animated shapes, and line art using primary color and complementary colors - Add automatic screen clearing every 3 minutes (Windows 98 style) and every few seconds for variety - Create ScreensaverButton component positioned next to ColorPickerButton - Integrate screensaver into both DesktopLayout and MobileLayout - Screensaver closes on any user interaction (key press, mouse movement, click, touch) - Uses color palette from ColorPicker to select complementary colors automatically
This commit is contained in:
parent
dbf19eeb60
commit
55ba2e16e5
276
client/src/components/Screensaver.tsx
Normal file
276
client/src/components/Screensaver.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Box, IconButton } from '@mui/material';
|
||||
import { Close } from '@mui/icons-material';
|
||||
import { useThemeStore } from '../stores/themeStore';
|
||||
|
||||
interface ScreensaverProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Color palette from ColorPicker for complementary color selection
|
||||
const colorPalette = [
|
||||
'#673ab7', '#9c27b0', '#e1bee7', // Purples
|
||||
'#1976d2', '#03dac6', '#3f51b5', // Blues
|
||||
'#2e7d32', '#4caf50', '#009688', // Greens
|
||||
'#d32f2f', '#ff5722', '#ff9800', // Reds/Oranges
|
||||
'#424242', '#607d8b', '#795548', // Grays
|
||||
];
|
||||
|
||||
// Get complementary colors based on primary color
|
||||
function getComplementaryColors(primaryColor: string): string[] {
|
||||
const primaryIndex = colorPalette.findIndex(color => color === primaryColor);
|
||||
if (primaryIndex === -1) {
|
||||
// If primary color is not in palette, use default complementary colors
|
||||
return ['#ff5722', '#4caf50']; // Orange and Green
|
||||
}
|
||||
|
||||
// Choose colors that are visually distinct from primary
|
||||
const complementaryIndices = [
|
||||
(primaryIndex + 6) % colorPalette.length, // Opposite side
|
||||
(primaryIndex + 9) % colorPalette.length, // Further opposite
|
||||
];
|
||||
|
||||
return complementaryIndices.map(index => colorPalette[index]);
|
||||
}
|
||||
|
||||
// Create gradient pattern
|
||||
function createGradientPattern(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
colors: string[],
|
||||
time: number
|
||||
) {
|
||||
const gradient1 = ctx.createRadialGradient(
|
||||
width * 0.3 + Math.sin(time * 0.001) * 100,
|
||||
height * 0.3 + Math.cos(time * 0.001) * 100,
|
||||
0,
|
||||
width * 0.3 + Math.sin(time * 0.001) * 100,
|
||||
height * 0.3 + Math.cos(time * 0.001) * 100,
|
||||
width * 0.8
|
||||
);
|
||||
|
||||
gradient1.addColorStop(0, `${colors[0]}80`);
|
||||
gradient1.addColorStop(0.5, `${colors[1]}40`);
|
||||
gradient1.addColorStop(1, `${colors[2]}20`);
|
||||
|
||||
ctx.fillStyle = gradient1;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
const gradient2 = ctx.createRadialGradient(
|
||||
width * 0.7 + Math.cos(time * 0.002) * 150,
|
||||
height * 0.7 + Math.sin(time * 0.002) * 150,
|
||||
0,
|
||||
width * 0.7 + Math.cos(time * 0.002) * 150,
|
||||
height * 0.7 + Math.sin(time * 0.002) * 150,
|
||||
width * 0.6
|
||||
);
|
||||
|
||||
gradient2.addColorStop(0, `${colors[2]}60`);
|
||||
gradient2.addColorStop(0.7, `${colors[0]}30`);
|
||||
gradient2.addColorStop(1, 'transparent');
|
||||
|
||||
ctx.fillStyle = gradient2;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
// Draw animated shapes
|
||||
function drawShapes(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
colors: string[],
|
||||
time: number
|
||||
) {
|
||||
const numShapes = 8;
|
||||
|
||||
for (let i = 0; i < numShapes; i++) {
|
||||
const x = width * 0.5 + Math.sin(time * 0.001 + i * 0.8) * (width * 0.3);
|
||||
const y = height * 0.5 + Math.cos(time * 0.001 + i * 0.8) * (height * 0.3);
|
||||
const size = 50 + Math.sin(time * 0.002 + i) * 30;
|
||||
const colorIndex = i % colors.length;
|
||||
const opacity = 0.3 + Math.sin(time * 0.003 + i) * 0.2;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.fillStyle = colors[colorIndex];
|
||||
|
||||
if (i % 3 === 0) {
|
||||
// Circles
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (i % 3 === 1) {
|
||||
// Squares
|
||||
ctx.fillRect(x - size/2, y - size/2, size, size);
|
||||
} else {
|
||||
// Triangles
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y - size/2);
|
||||
ctx.lineTo(x - size/2, y + size/2);
|
||||
ctx.lineTo(x + size/2, y + size/2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// Draw line art
|
||||
function drawLineArt(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
colors: string[],
|
||||
time: number
|
||||
) {
|
||||
const numLines = 12;
|
||||
|
||||
for (let i = 0; i < numLines; i++) {
|
||||
const x1 = Math.sin(time * 0.0005 + i * 0.5) * width;
|
||||
const y1 = Math.cos(time * 0.0005 + i * 0.5) * height;
|
||||
const x2 = Math.sin(time * 0.0005 + i * 0.5 + Math.PI) * width;
|
||||
const y2 = Math.cos(time * 0.0005 + i * 0.5 + Math.PI) * height;
|
||||
|
||||
const colorIndex = i % colors.length;
|
||||
const opacity = 0.2 + Math.sin(time * 0.001 + i) * 0.1;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.strokeStyle = colors[colorIndex];
|
||||
ctx.lineWidth = 2 + Math.sin(time * 0.002 + i) * 1;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
export function Screensaver({ onClose }: ScreensaverProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>();
|
||||
const { primaryColor } = useThemeStore();
|
||||
const [lastClearTime, setLastClearTime] = useState(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size
|
||||
const resizeCanvas = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
// Get complementary colors
|
||||
const complementaryColors = getComplementaryColors(primaryColor);
|
||||
const colors = [primaryColor, ...complementaryColors];
|
||||
|
||||
let startTime = Date.now();
|
||||
let clearInterval = 0;
|
||||
|
||||
const animate = () => {
|
||||
const currentTime = Date.now();
|
||||
const elapsed = currentTime - startTime;
|
||||
|
||||
// Clear screen every 3 minutes (180000ms) like Windows 98
|
||||
if (currentTime - lastClearTime > 180000) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
setLastClearTime(currentTime);
|
||||
clearInterval = 0;
|
||||
}
|
||||
|
||||
// Clear screen every few seconds for variety
|
||||
if (clearInterval > 5000) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
clearInterval = 0;
|
||||
}
|
||||
|
||||
// Create gradient background
|
||||
createGradientPattern(ctx, canvas.width, canvas.height, colors, elapsed);
|
||||
|
||||
// Draw shapes
|
||||
drawShapes(ctx, canvas.width, canvas.height, colors, elapsed);
|
||||
|
||||
// Draw line art
|
||||
drawLineArt(ctx, canvas.width, canvas.height, colors, elapsed);
|
||||
|
||||
clearInterval += 16; // ~60fps
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeCanvas);
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [primaryColor, lastClearTime]);
|
||||
|
||||
// Close on any key press or mouse movement
|
||||
useEffect(() => {
|
||||
const handleInteraction = () => onClose();
|
||||
|
||||
window.addEventListener('keydown', handleInteraction);
|
||||
window.addEventListener('mousemove', handleInteraction);
|
||||
window.addEventListener('click', handleInteraction);
|
||||
window.addEventListener('touchstart', handleInteraction);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleInteraction);
|
||||
window.removeEventListener('mousemove', handleInteraction);
|
||||
window.removeEventListener('click', handleInteraction);
|
||||
window.removeEventListener('touchstart', handleInteraction);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: 9999,
|
||||
bgcolor: 'black',
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
color: 'white',
|
||||
bgcolor: 'rgba(0, 0, 0, 0.5)',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(0, 0, 0, 0.7)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
29
client/src/components/ScreensaverButton.tsx
Normal file
29
client/src/components/ScreensaverButton.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
import { Slideshow } from '@mui/icons-material';
|
||||
import { Screensaver } from './Screensaver';
|
||||
|
||||
export function ScreensaverButton() {
|
||||
const [screensaverOpen, setScreensaverOpen] = useState(false);
|
||||
|
||||
const handleOpenScreensaver = () => {
|
||||
setScreensaverOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseScreensaver = () => {
|
||||
setScreensaverOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="Launch screensaver">
|
||||
<IconButton onClick={handleOpenScreensaver}>
|
||||
<Slideshow />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{screensaverOpen && (
|
||||
<Screensaver onClose={handleCloseScreensaver} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -3,6 +3,7 @@ import PrintIcon from '@mui/icons-material/Print';
|
||||
import type { GroupWithTasks, TaskWithSteps, StepWithNotes } from '../types';
|
||||
import { CreateButtons } from '../components/CreateButtons';
|
||||
import { ColorPickerButton } from '../components/ColorPickerButton';
|
||||
import { ScreensaverButton } from '../components/ScreensaverButton';
|
||||
import { useUserSelection } from '../hooks/useUserSelection';
|
||||
|
||||
interface DesktopLayoutProps {
|
||||
@ -48,6 +49,7 @@ export function DesktopLayout({
|
||||
<Typography variant="h6">Groups</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<ColorPickerButton />
|
||||
<ScreensaverButton />
|
||||
<CreateButtons type="group" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
@ -4,6 +4,7 @@ import PrintIcon from '@mui/icons-material/Print';
|
||||
import type { GroupWithTasks, TaskWithSteps, StepWithNotes } from '../types';
|
||||
import { CreateButtons } from '../components/CreateButtons';
|
||||
import { ColorPickerButton } from '../components/ColorPickerButton';
|
||||
import { ScreensaverButton } from '../components/ScreensaverButton';
|
||||
import { useUserSelection } from '../hooks/useUserSelection';
|
||||
|
||||
function isGroupView(selectedGroup?: GroupWithTasks) {
|
||||
@ -106,6 +107,7 @@ export function MobileLayout({
|
||||
<Typography variant="h6">Groups</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<ColorPickerButton />
|
||||
<ScreensaverButton />
|
||||
<CreateButtons type="group" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
Loading…
Reference in New Issue
Block a user