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 type { GroupWithTasks, TaskWithSteps, StepWithNotes } from '../types';
|
||||||
import { CreateButtons } from '../components/CreateButtons';
|
import { CreateButtons } from '../components/CreateButtons';
|
||||||
import { ColorPickerButton } from '../components/ColorPickerButton';
|
import { ColorPickerButton } from '../components/ColorPickerButton';
|
||||||
|
import { ScreensaverButton } from '../components/ScreensaverButton';
|
||||||
import { useUserSelection } from '../hooks/useUserSelection';
|
import { useUserSelection } from '../hooks/useUserSelection';
|
||||||
|
|
||||||
interface DesktopLayoutProps {
|
interface DesktopLayoutProps {
|
||||||
@ -48,6 +49,7 @@ export function DesktopLayout({
|
|||||||
<Typography variant="h6">Groups</Typography>
|
<Typography variant="h6">Groups</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
<ColorPickerButton />
|
<ColorPickerButton />
|
||||||
|
<ScreensaverButton />
|
||||||
<CreateButtons type="group" />
|
<CreateButtons type="group" />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
@ -4,6 +4,7 @@ import PrintIcon from '@mui/icons-material/Print';
|
|||||||
import type { GroupWithTasks, TaskWithSteps, StepWithNotes } from '../types';
|
import type { GroupWithTasks, TaskWithSteps, StepWithNotes } from '../types';
|
||||||
import { CreateButtons } from '../components/CreateButtons';
|
import { CreateButtons } from '../components/CreateButtons';
|
||||||
import { ColorPickerButton } from '../components/ColorPickerButton';
|
import { ColorPickerButton } from '../components/ColorPickerButton';
|
||||||
|
import { ScreensaverButton } from '../components/ScreensaverButton';
|
||||||
import { useUserSelection } from '../hooks/useUserSelection';
|
import { useUserSelection } from '../hooks/useUserSelection';
|
||||||
|
|
||||||
function isGroupView(selectedGroup?: GroupWithTasks) {
|
function isGroupView(selectedGroup?: GroupWithTasks) {
|
||||||
@ -106,6 +107,7 @@ export function MobileLayout({
|
|||||||
<Typography variant="h6">Groups</Typography>
|
<Typography variant="h6">Groups</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
<ColorPickerButton />
|
<ColorPickerButton />
|
||||||
|
<ScreensaverButton />
|
||||||
<CreateButtons type="group" />
|
<CreateButtons type="group" />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
Loading…
Reference in New Issue
Block a user