From 55ba2e16e5dc9c180406d5b009df8f5caf26f792 Mon Sep 17 00:00:00 2001 From: Sean Sube Date: Tue, 17 Jun 2025 20:23:29 -0500 Subject: [PATCH] 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 --- client/src/components/Screensaver.tsx | 276 ++++++++++++++++++++ client/src/components/ScreensaverButton.tsx | 29 ++ client/src/layouts/DesktopLayout.tsx | 2 + client/src/layouts/MobileLayout.tsx | 2 + 4 files changed, 309 insertions(+) create mode 100644 client/src/components/Screensaver.tsx create mode 100644 client/src/components/ScreensaverButton.tsx diff --git a/client/src/components/Screensaver.tsx b/client/src/components/Screensaver.tsx new file mode 100644 index 0000000..1706a13 --- /dev/null +++ b/client/src/components/Screensaver.tsx @@ -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(null); + const animationRef = useRef(); + 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 ( + + + + + + + ); +} \ No newline at end of file diff --git a/client/src/components/ScreensaverButton.tsx b/client/src/components/ScreensaverButton.tsx new file mode 100644 index 0000000..cdfa791 --- /dev/null +++ b/client/src/components/ScreensaverButton.tsx @@ -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 ( + <> + + + + + + {screensaverOpen && ( + + )} + + ); +} \ No newline at end of file diff --git a/client/src/layouts/DesktopLayout.tsx b/client/src/layouts/DesktopLayout.tsx index 7039bcb..c3f68fc 100644 --- a/client/src/layouts/DesktopLayout.tsx +++ b/client/src/layouts/DesktopLayout.tsx @@ -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({ Groups + diff --git a/client/src/layouts/MobileLayout.tsx b/client/src/layouts/MobileLayout.tsx index d8d9c64..be452da 100644 --- a/client/src/layouts/MobileLayout.tsx +++ b/client/src/layouts/MobileLayout.tsx @@ -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({ Groups +