Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 144 additions & 69 deletions src/components/CountdownTimer.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { useState, useEffect, useRef } from "react";

const playSound = (audioRef) => {
if (audioRef.current) {
audioRef.current.currentTime = 0;
audioRef.current.play().catch(e => console.error("Error playing sound:", e));
}
};

const STROKE_WIDTH = 10;
const RADIUS = 70;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;

const CountdownTimer = ({
duration = 30,
onTimeUp,
Expand All @@ -10,24 +21,63 @@ const CountdownTimer = ({
className = "",
initialTimeRemaining = null,
onTimeUpdate = null,
startSoundSrc = null,
warningSoundSrc = null,
timeUpSoundSrc = null,
finalCountdownSoundSrc = null,
isMuted = false,
}) => {
const [timeRemaining, setTimeRemaining] = useState(initialTimeRemaining ?? duration);
const intervalRef = useRef(null);
const hasWarningFired = useRef(false);
const hasTimeUpFired = useRef(false);
const hasStartedFired = useRef(false);
const lastCountdownSoundTime = useRef(0);

const startAudioRef = useRef(null);
const warningAudioRef = useRef(null);
const timeUpAudioRef = useRef(null);
const finalCountdownAudioRef = useRef(null);

useEffect(() => {
if (!isMuted) {
if (startSoundSrc) startAudioRef.current = new Audio(startSoundSrc);
if (warningSoundSrc) warningAudioRef.current = new Audio(warningSoundSrc);
if (timeUpSoundSrc) timeUpAudioRef.current = new Audio(timeUpSoundSrc);
if (finalCountdownSoundSrc) finalCountdownAudioRef.current = new Audio(finalCountdownSoundSrc);
}

return () => {
[startAudioRef, warningAudioRef, timeUpAudioRef, finalCountdownAudioRef].forEach(ref => {
if (ref.current) {
ref.current.pause();
ref.current = null;
}
});
};
}, [isMuted, startSoundSrc, warningSoundSrc, timeUpSoundSrc, finalCountdownSoundSrc]);

// Reset when duration or initial value changes (e.g., new question)
useEffect(() => {
setTimeRemaining(initialTimeRemaining ?? duration);
hasWarningFired.current = false;
hasTimeUpFired.current = false;
hasStartedFired.current = false;
lastCountdownSoundTime.current = 0;
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, [duration, initialTimeRemaining]);

// Start/stop the interval based on pause/active flags
useEffect(() => {
if (isActive && !isPaused && !hasStartedFired.current) {
if (initialTimeRemaining === null || initialTimeRemaining !== duration) {
if (!isMuted) playSound(startAudioRef);
hasStartedFired.current = true;
}
}
}, [isActive, isPaused, duration, initialTimeRemaining, isMuted]);

useEffect(() => {
if (!isActive || isPaused || timeRemaining <= 0) {
if (intervalRef.current) {
Expand All @@ -41,28 +91,30 @@ const CountdownTimer = ({
setTimeRemaining((prev) => {
const next = Math.max(prev - 1, 0);

// onTimeUpdate callback, invoked in a microtask to avoid potential react state warnings
if (onTimeUpdate) {
Promise.resolve().then(() => onTimeUpdate(next));
}

// Warning callback
if (next > 0 && next <= 5 && next !== lastCountdownSoundTime.current) {
if (!isMuted) playSound(finalCountdownAudioRef);
lastCountdownSoundTime.current = next;
}

if (next === showWarningAt && !hasWarningFired.current && onWarning) {
hasWarningFired.current = true;
if (!isMuted) playSound(warningAudioRef);
onWarning();
}

// Time up logic
if (next === 0) {
if (!hasTimeUpFired.current) {
hasTimeUpFired.current = true;
if (!isMuted) playSound(timeUpAudioRef);

// Global handler for QuizQuestion, if it exists
if (typeof window !== "undefined" && window.quizQuestionHandleTimeOut) {
window.quizQuestionHandleTimeOut();
}

// onTimeUp callback

if (onTimeUp) {
onTimeUp();
}
Expand All @@ -79,91 +131,114 @@ const CountdownTimer = ({
intervalRef.current = null;
}
};
}, [isActive, isPaused, timeRemaining, onTimeUp, onTimeUpdate, onWarning, showWarningAt]);
}, [isActive, isPaused, timeRemaining, onTimeUp, onTimeUpdate, onWarning, showWarningAt, isMuted]);

const progressPercentage = Math.max(0, Math.min(100, (timeRemaining / duration) * 100));

const getTimerColor = () => {
const percentage = (timeRemaining / duration) * 100;
if (percentage <= 20) return "text-red-500";
if (percentage <= 40) return "text-yellow-500";
return "text-green-500";
const isWarning = timeRemaining > 0 && timeRemaining <= showWarningAt && !isPaused;
const isDanger = timeRemaining > 0 && timeRemaining <= 5 && !isPaused;
const isTimeUp = timeRemaining === 0;

const progressOffset = CIRCUMFERENCE - (progressPercentage / 100) * CIRCUMFERENCE;
const center = RADIUS + STROKE_WIDTH;
const size = center * 2;

const getRingColorClass = () => {
if (isTimeUp || progressPercentage <= (showWarningAt / duration) * 100) return "stroke-red-500";
if (progressPercentage <= 40) return "stroke-yellow-500";
return "stroke-green-500";
};

const getProgressBarColor = () => {
const percentage = (timeRemaining / duration) * 100;
if (percentage <= 20) return "bg-red-500";
if (percentage <= 40) return "bg-yellow-500";
return "bg-green-500";
};

const getProgressBarGlow = () => {
const percentage = (timeRemaining / duration) * 100;
if (percentage <= 20) return "shadow-red-500/50";
if (percentage <= 40) return "shadow-yellow-500/50";
return "shadow-green-500/50";
const getTimerTextColor = () => {
if (isTimeUp || progressPercentage <= (showWarningAt / duration) * 100) return "text-red-500";
if (progressPercentage <= 40) return "text-yellow-500";
return "text-green-500";
};

const formatTime = (seconds) => {
if (seconds >= 60) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
const totalMinutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
const formattedSeconds = String(remainingSeconds).padStart(2, '0');

if (totalMinutes > 0) {
return `${totalMinutes}m ${formattedSeconds}s`;
}
return `${seconds}s`;
};

if (!isActive) return null;

return (
<div className={`w-full ${className}`}>
<div className="flex items-center justify-center gap-3 mb-4">
<div
className={`text-2xl transition-colors duration-300 ${getTimerColor()}`}
>
{isPaused ? "⏸️" : "⏱️"}
</div>
<span
className={`text-xl font-bold transition-colors duration-300 ${getTimerColor()}`}
>
{isPaused ? "Timer Paused: " : "Time Remaining: "}
{formatTime(timeRemaining)}
</span>
</div>

<div className="relative w-full h-4 bg-gray-200 rounded-full overflow-hidden">
<div className="absolute inset-0 bg-gray-300 rounded-full"></div>

<div
className={`absolute left-0 top-0 h-full rounded-full transition-all duration-1000 ease-linear ${getProgressBarColor()} ${getProgressBarGlow()} shadow-lg ${isPaused ? "opacity-70" : ""}`}
style={{
width: `${progressPercentage}%`,
transition: isPaused
? "opacity 0.3s ease, background-color 0.3s ease"
: "width 1s linear, background-color 0.3s ease",
}}
<div className={`flex flex-col items-center justify-center w-full ${className}`}>

<div className={`relative flex items-center justify-center ${isDanger && 'animate-shake'}`}>
<svg
className={`transform -rotate-90 ${isPaused ? 'opacity-70' : ''}`}
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent rounded-full"></div>
<circle
cx={center}
cy={center}
r={RADIUS}
strokeWidth={STROKE_WIDTH}
className="stroke-gray-300 fill-transparent"
/>

<circle
cx={center}
cy={center}
r={RADIUS}
strokeWidth={STROKE_WIDTH}
className={`${getRingColorClass()} fill-transparent transition-all duration-1000 ${isPaused ? 'ease-out' : 'ease-linear'}`}
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={progressOffset}
strokeLinecap="round"
style={{
transition: isPaused
? "opacity 0.3s ease, stroke 0.3s ease"
: "stroke-dashoffset 1s linear, stroke 0.3s ease",
}}
/>
</svg>

<div className="absolute inset-0 flex flex-col items-center justify-center">
<span
className={`text-3xl font-extrabold transition-colors duration-300 ${getTimerTextColor()} ${isDanger ? 'animate-pulse' : ''}`}
>
{formatTime(timeRemaining)}
</span>
<span className="text-sm text-gray-500 mt-1">
{isPaused ? "Paused" : (isTimeUp ? "TIME UP" : "Remaining")}
</span>
</div>

{timeRemaining <= showWarningAt && !isPaused && (
{isWarning && !isPaused && (
<div
className={`absolute inset-0 rounded-full animate-pulse ${getProgressBarColor()} opacity-30`}
className={`absolute inset-0 rounded-full animate-pulse opacity-20 ${getRingColorClass().replace('stroke-', 'bg-')}`}
style={{ width: size, height: size }}
></div>
)}

{isPaused && (
<div className="absolute inset-0 rounded-full bg-orange-400 opacity-20 animate-pulse"></div>
)}
</div>

{timeRemaining === 0 && (
<div className="mt-4 text-center">
<span className="inline-block bg-red-500 text-white px-4 py-2 rounded-full font-bold text-sm animate-pulse">
⏱️ TIME'S UP!
{isTimeUp && (
<div className="mt-6 text-center">
<span className="inline-block bg-red-500 text-white px-5 py-2 rounded-full font-bold text-lg animate-pulse shadow-lg shadow-red-500/50">
🛑 TIME'S UP! 🛑
</span>
</div>
)}

<style jsx global>{`
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-3px); }
40%, 80% { transform: translateX(3px); }
}
.animate-shake {
animation: shake 0.5s ease-in-out;
}
`}</style>
</div>
);
};
Expand Down