import React, { useState, useEffect, useRef, useCallback } from 'react'; import { supabase } from '@/lib/supabase'; import { Timer, Play, Pause, RotateCcw, Maximize, Minimize, Volume2, VolumeX, Plus, Minus, Settings, Palette, Music, ChevronDown, X, Monitor, Waves, TreePine, Sparkles, CloudRain, Sun, Moon, Fish, Mountain, Loader2, Download, Check } from 'lucide-react'; // ─── Sensory Backgrounds ─── const SENSORY_BACKGROUNDS = [ { id: 'ocean', name: 'Ocean Calm', icon: , image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662475079_dabe421c.png', overlay: 'from-blue-950/40 via-blue-900/20 to-cyan-950/40', textColor: 'text-cyan-100', accentColor: 'text-cyan-300', ringColor: 'stroke-cyan-400', trackColor: 'stroke-cyan-900/50', animation: 'animate-ocean', }, { id: 'aurora', name: 'Aurora Borealis', icon: , image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662490787_3aabfbd4.jpg', overlay: 'from-purple-950/40 via-emerald-950/20 to-indigo-950/40', textColor: 'text-emerald-100', accentColor: 'text-emerald-300', ringColor: 'stroke-emerald-400', trackColor: 'stroke-emerald-900/50', animation: 'animate-aurora', }, { id: 'lava', name: 'Lava Lamp', icon: , image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662521371_be5368c4.png', overlay: 'from-orange-950/40 via-red-950/20 to-purple-950/40', textColor: 'text-orange-100', accentColor: 'text-orange-300', ringColor: 'stroke-orange-400', trackColor: 'stroke-orange-900/50', animation: 'animate-lava', }, { id: 'galaxy', name: 'Deep Galaxy', icon: , image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662574948_03baf94b.png', overlay: 'from-indigo-950/40 via-purple-950/20 to-blue-950/40', textColor: 'text-purple-100', accentColor: 'text-purple-300', ringColor: 'stroke-purple-400', trackColor: 'stroke-purple-900/50', animation: 'animate-galaxy', }, { id: 'forest', name: 'Enchanted Forest', icon: , image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662588347_123efdd7.jpg', overlay: 'from-green-950/40 via-emerald-950/20 to-green-950/40', textColor: 'text-green-100', accentColor: 'text-green-300', ringColor: 'stroke-green-400', trackColor: 'stroke-green-900/50', animation: 'animate-forest', }, { id: 'rain', name: 'Rainy Day', icon: , image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662620449_93a5fbcd.png', overlay: 'from-slate-950/40 via-blue-950/20 to-slate-950/40', textColor: 'text-blue-100', accentColor: 'text-blue-300', ringColor: 'stroke-blue-400', trackColor: 'stroke-blue-900/50', animation: 'animate-rain', }, { id: 'coral', name: 'Coral Reef', icon: , image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662661000_e74a933f.png', overlay: 'from-teal-950/40 via-cyan-950/20 to-teal-950/40', textColor: 'text-teal-100', accentColor: 'text-teal-300', ringColor: 'stroke-teal-400', trackColor: 'stroke-teal-900/50', animation: 'animate-coral', }, { id: 'sunset', name: 'Sunset Sky', icon: , image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770662744948_e27daa4c.png', overlay: 'from-rose-950/40 via-amber-950/20 to-purple-950/40', textColor: 'text-rose-100', accentColor: 'text-rose-300', ringColor: 'stroke-rose-400', trackColor: 'stroke-rose-900/50', animation: 'animate-sunset', }, ]; // ─── Sound Options ─── const SOUND_OPTIONS = [ { id: 'gentle-chime', name: 'Gentle Chime', icon: '🔔', frequency: 523.25, type: 'chime' as const }, { id: 'soft-bell', name: 'Soft Bell', icon: '🛎', frequency: 440, type: 'bell' as const }, { id: 'xylophone', name: 'Xylophone', icon: '🎵', frequency: 659.25, type: 'xylophone' as const }, { id: 'singing-bowl', name: 'Singing Bowl', icon: '🎶', frequency: 256, type: 'bowl' as const }, { id: 'nature-birds', name: 'Nature Birds', icon: '🐦', frequency: 880, type: 'birds' as const }, { id: 'ocean-wave', name: 'Ocean Wave', icon: '🌊', frequency: 200, type: 'wave' as const }, { id: 'rain-stick', name: 'Rain Stick', icon: '🌧', frequency: 300, type: 'rain' as const }, { id: 'harp-gliss', name: 'Harp Glissando', icon: '🎵', frequency: 392, type: 'harp' as const }, ]; // ─── Preset Times ─── const PRESET_TIMES = [ { label: '30s', seconds: 30 }, { label: '1 min', seconds: 60 }, { label: '2 min', seconds: 120 }, { label: '3 min', seconds: 180 }, { label: '5 min', seconds: 300 }, { label: '10 min', seconds: 600 }, { label: '15 min', seconds: 900 }, { label: '20 min', seconds: 1200 }, { label: '25 min', seconds: 1500 }, { label: '30 min', seconds: 1800 }, ]; // ─── Web Audio Sound Generator ─── function playBuiltInSound(soundType: string, audioCtx: AudioContext) { const now = audioCtx.currentTime; switch (soundType) { case 'gentle-chime': { const freqs = [523.25, 659.25, 783.99, 1046.5]; freqs.forEach((freq, i) => { const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'sine'; osc.frequency.value = freq; gain.gain.setValueAtTime(0, now + i * 0.3); gain.gain.linearRampToValueAtTime(0.3, now + i * 0.3 + 0.05); gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.3 + 1.5); osc.connect(gain).connect(audioCtx.destination); osc.start(now + i * 0.3); osc.stop(now + i * 0.3 + 1.5); }); break; } case 'soft-bell': { const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'sine'; osc.frequency.value = 440; gain.gain.setValueAtTime(0.4, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 3); osc.connect(gain).connect(audioCtx.destination); osc.start(now); osc.stop(now + 3); // Overtone const osc2 = audioCtx.createOscillator(); const gain2 = audioCtx.createGain(); osc2.type = 'sine'; osc2.frequency.value = 880; gain2.gain.setValueAtTime(0.15, now); gain2.gain.exponentialRampToValueAtTime(0.001, now + 2); osc2.connect(gain2).connect(audioCtx.destination); osc2.start(now); osc2.stop(now + 2); break; } case 'xylophone': { const notes = [523.25, 587.33, 659.25, 783.99, 880, 783.99, 659.25]; notes.forEach((freq, i) => { const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'triangle'; osc.frequency.value = freq; gain.gain.setValueAtTime(0, now + i * 0.15); gain.gain.linearRampToValueAtTime(0.35, now + i * 0.15 + 0.01); gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.15 + 0.6); osc.connect(gain).connect(audioCtx.destination); osc.start(now + i * 0.15); osc.stop(now + i * 0.15 + 0.6); }); break; } case 'singing-bowl': { const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'sine'; osc.frequency.value = 256; osc.frequency.linearRampToValueAtTime(260, now + 4); gain.gain.setValueAtTime(0, now); gain.gain.linearRampToValueAtTime(0.35, now + 0.3); gain.gain.setValueAtTime(0.35, now + 1); gain.gain.exponentialRampToValueAtTime(0.001, now + 5); osc.connect(gain).connect(audioCtx.destination); osc.start(now); osc.stop(now + 5); // Harmonic const osc2 = audioCtx.createOscillator(); const gain2 = audioCtx.createGain(); osc2.type = 'sine'; osc2.frequency.value = 512; gain2.gain.setValueAtTime(0, now); gain2.gain.linearRampToValueAtTime(0.1, now + 0.3); gain2.gain.exponentialRampToValueAtTime(0.001, now + 4); osc2.connect(gain2).connect(audioCtx.destination); osc2.start(now); osc2.stop(now + 4); break; } case 'harp-gliss': { const scale = [261.63, 293.66, 329.63, 349.23, 392, 440, 493.88, 523.25, 587.33, 659.25, 698.46, 783.99]; scale.forEach((freq, i) => { const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'sine'; osc.frequency.value = freq; gain.gain.setValueAtTime(0, now + i * 0.08); gain.gain.linearRampToValueAtTime(0.2, now + i * 0.08 + 0.02); gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 1.2); osc.connect(gain).connect(audioCtx.destination); osc.start(now + i * 0.08); osc.stop(now + i * 0.08 + 1.2); }); break; } default: { // Default gentle tone const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'sine'; osc.frequency.value = 523.25; gain.gain.setValueAtTime(0.3, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 2); osc.connect(gain).connect(audioCtx.destination); osc.start(now); osc.stop(now + 2); } } } // ─── Circular Progress Component ─── const CircularProgress: React.FC<{ progress: number; size: number; strokeWidth: number; ringClass: string; trackClass: string; }> = ({ progress, size, strokeWidth, ringClass, trackClass }) => { const radius = (size - strokeWidth) / 2; const circumference = 2 * Math.PI * radius; const offset = circumference - progress * circumference; return ( ); }; // ─── Main Component ─── const ClassroomTimer: React.FC = () => { const [totalSeconds, setTotalSeconds] = useState(300); // 5 min default const [remainingSeconds, setRemainingSeconds] = useState(300); const [isRunning, setIsRunning] = useState(false); const [isFinished, setIsFinished] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [selectedBackground, setSelectedBackground] = useState(SENSORY_BACKGROUNDS[0]); const [selectedSound, setSelectedSound] = useState(SOUND_OPTIONS[0]); const [soundEnabled, setSoundEnabled] = useState(true); const [showSettings, setShowSettings] = useState(false); const [showBackgrounds, setShowBackgrounds] = useState(false); const [showSounds, setShowSounds] = useState(false); const [customMinutes, setCustomMinutes] = useState(5); const [customSeconds, setCustomSeconds] = useState(0); const [isLoadingAiSound, setIsLoadingAiSound] = useState(false); const [aiSoundUrl, setAiSoundUrl] = useState(null); const [useAiSound, setUseAiSound] = useState(false); const [aiSoundCached, setAiSoundCached] = useState>({}); const [previewPlaying, setPreviewPlaying] = useState(false); const intervalRef = useRef(null); const audioCtxRef = useRef(null); const fullscreenRef = useRef(null); const aiAudioRef = useRef(null); // Initialize AudioContext on first user interaction const getAudioContext = useCallback(() => { if (!audioCtxRef.current) { audioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)(); } if (audioCtxRef.current.state === 'suspended') { audioCtxRef.current.resume(); } return audioCtxRef.current; }, []); // Timer logic useEffect(() => { if (isRunning && remainingSeconds > 0) { intervalRef.current = setInterval(() => { setRemainingSeconds(prev => { if (prev <= 1) { setIsRunning(false); setIsFinished(true); return 0; } return prev - 1; }); }, 1000); } return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; }, [isRunning, remainingSeconds]); // Play sound when finished useEffect(() => { if (isFinished && soundEnabled) { if (useAiSound && aiSoundUrl) { const audio = new Audio(aiSoundUrl); audio.play().catch(() => {}); } else { const ctx = getAudioContext(); playBuiltInSound(selectedSound.id, ctx); // Play again after 3 seconds for emphasis setTimeout(() => { playBuiltInSound(selectedSound.id, ctx); }, 3000); } } }, [isFinished]); // Format time const formatTime = (secs: number) => { const m = Math.floor(secs / 60); const s = secs % 60; return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; }; const progress = totalSeconds > 0 ? remainingSeconds / totalSeconds : 0; const handleStart = () => { if (remainingSeconds <= 0) return; getAudioContext(); // Ensure audio context is ready setIsRunning(true); setIsFinished(false); }; const handlePause = () => { setIsRunning(false); }; const handleReset = () => { setIsRunning(false); setIsFinished(false); setRemainingSeconds(totalSeconds); }; const handleSetTime = (seconds: number) => { setIsRunning(false); setIsFinished(false); setTotalSeconds(seconds); setRemainingSeconds(seconds); }; const handleCustomTime = () => { const total = customMinutes * 60 + customSeconds; if (total > 0) { handleSetTime(total); } }; // Fullscreen toggle const toggleFullscreen = async () => { if (!isFullscreen) { try { if (fullscreenRef.current) { await fullscreenRef.current.requestFullscreen(); } } catch { // Fallscreen fallback } setIsFullscreen(true); } else { try { if (document.fullscreenElement) { await document.exitFullscreen(); } } catch { // ignore } setIsFullscreen(false); } }; // Listen for fullscreen exit useEffect(() => { const handleFsChange = () => { if (!document.fullscreenElement) { setIsFullscreen(false); } }; document.addEventListener('fullscreenchange', handleFsChange); return () => document.removeEventListener('fullscreenchange', handleFsChange); }, []); // Generate AI sound const generateAiSound = async (soundId: string) => { if (aiSoundCached[soundId]) { setAiSoundUrl(aiSoundCached[soundId]); setUseAiSound(true); return; } setIsLoadingAiSound(true); try { const response = await supabase.functions.invoke('generate-timer-sound', { body: { soundType: soundId }, }); if (response.error) throw response.error; // response.data is already a Blob or we need to handle it let blob: Blob; if (response.data instanceof Blob) { blob = response.data; } else { // If it's not a blob, try to create one blob = new Blob([response.data], { type: 'audio/mpeg' }); } const url = URL.createObjectURL(blob); setAiSoundUrl(url); setAiSoundCached(prev => ({ ...prev, [soundId]: url })); setUseAiSound(true); } catch (err) { console.error('Failed to generate AI sound:', err); setUseAiSound(false); } finally { setIsLoadingAiSound(false); } }; // Preview sound const previewSound = (soundId: string) => { if (previewPlaying) return; setPreviewPlaying(true); if (useAiSound && aiSoundCached[soundId]) { const audio = new Audio(aiSoundCached[soundId]); audio.play().catch(() => {}); audio.onended = () => setPreviewPlaying(false); setTimeout(() => setPreviewPlaying(false), 3000); } else { const ctx = getAudioContext(); playBuiltInSound(soundId, ctx); setTimeout(() => setPreviewPlaying(false), 2000); } }; // Get urgency color based on remaining time const getUrgencyColor = () => { if (isFinished) return 'text-red-400 animate-pulse'; if (progress <= 0.1) return 'text-red-300'; if (progress <= 0.25) return 'text-amber-300'; return selectedBackground.textColor; }; // ─── Fullscreen / Projection View ─── const renderProjectionView = () => (
{/* Background Image */} {/* Gradient Overlay */}
{/* Animated overlay effects */}
{/* Floating particles */} {[...Array(20)].map((_, i) => (
))}
{/* Timer Content */}
{/* Circular Timer */}
{formatTime(remainingSeconds)} {isFinished && ( Time's Up! )}
{/* Controls */}
{!isRunning && !isFinished && ( )} {isRunning && ( )}
{/* Preset quick buttons in fullscreen */}
{PRESET_TIMES.slice(0, 6).map(p => ( ))}
); // ─── Normal View ─── return (
{/* CSS for animations */} {/* Header */}

Classroom Timer

Visual countdown timer with sensory backgrounds — perfect for transitions, work time, and breaks. Project on the big screen!

{/* Main Timer Area */}
{/* Timer Display */}
{/* Background */}
{/* Floating particles */}
{[...Array(12)].map((_, i) => (
))}
{/* Timer Content */}
{/* Circular Timer */}
{formatTime(remainingSeconds)} {isFinished && ( Time's Up! )} {!isRunning && !isFinished && remainingSeconds === totalSeconds && ( Ready )} {isRunning && ( Running... )}
{/* Controls */}
{!isRunning && !isFinished && ( )} {isRunning && ( )} {(isFinished || (!isRunning && remainingSeconds < totalSeconds)) && ( )}
{/* Quick presets row */}
{PRESET_TIMES.map(p => ( ))}
{/* Fullscreen hint */}
{/* Settings Panel */}
{/* Custom Time */}

Custom Time

setCustomMinutes(Math.max(0, Math.min(120, parseInt(e.target.value) || 0)))} className="w-14 h-8 bg-slate-700/50 border border-slate-600/50 rounded-lg text-center text-white text-sm font-mono focus:ring-2 focus:ring-cyan-500/50 outline-none" />
:
setCustomSeconds(Math.max(0, Math.min(59, parseInt(e.target.value) || 0)))} className="w-14 h-8 bg-slate-700/50 border border-slate-600/50 rounded-lg text-center text-white text-sm font-mono focus:ring-2 focus:ring-cyan-500/50 outline-none" />
{/* Sound Selection */}

Timer Sound

{SOUND_OPTIONS.map(sound => ( ))}
{/* AI Sound Generation */}

AI-Generated Sound

{useAiSound && ( )}
{/* Background Selection */}

Sensory Background

{SENSORY_BACKGROUNDS.map(bg => ( ))}
{/* Projection Button */}
{/* Tips Section */}

Timer Tips for Autism-Focused Classrooms

Transitions

Use 5-3-1 minute warnings before activity changes. The visual countdown reduces anxiety about unexpected transitions.

Sensory Backgrounds

Project calming backgrounds during work time or sensory breaks. Ocean and forest themes are great for de-escalation.

Sound Choices

Choose gentle sounds — avoid startling tones. Singing bowl and gentle chime work well for students sensitive to sudden sounds.

{/* Fullscreen View Portal */} {isFullscreen && renderProjectionView()}
); }; export default ClassroomTimer;