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:
Sean Sube 2025-06-17 20:23:29 -05:00
parent dbf19eeb60
commit 55ba2e16e5
No known key found for this signature in database
GPG Key ID: 3EED7B957D362AF1
4 changed files with 309 additions and 0 deletions

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

View 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} />
)}
</>
);
}

View File

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

View File

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