Timer Signal on Mobile with Notification
This commit is contained in:
81
src/hooks/timer.worker.ts
Normal file
81
src/hooks/timer.worker.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
// Web Worker to handle the timer interval in a background thread.
|
||||
// This prevents the timer from being throttled when the tab is inactive or screen is off.
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
let targetEndTime: number | null = null;
|
||||
|
||||
self.onmessage = (e: MessageEvent) => {
|
||||
const { type, payload } = e.data;
|
||||
|
||||
switch (type) {
|
||||
case 'START':
|
||||
if (payload?.endTime) {
|
||||
targetEndTime = payload.endTime;
|
||||
|
||||
// Clear any existing interval
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
|
||||
// Start a fast tick loop
|
||||
// We tick faster than 1s to ensure we don't miss the :00 mark by much
|
||||
intervalId = setInterval(() => {
|
||||
if (!targetEndTime) return;
|
||||
|
||||
const now = Date.now();
|
||||
const timeLeft = Math.max(0, Math.ceil((targetEndTime - now) / 1000));
|
||||
|
||||
// Send tick update
|
||||
self.postMessage({ type: 'TICK', timeLeft });
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
self.postMessage({ type: 'FINISHED' });
|
||||
|
||||
// Fire notification directly from worker to bypass frozen main thread
|
||||
if ('Notification' in self && (self as any).Notification.permission === 'granted') {
|
||||
try {
|
||||
// Try ServiceWorker registration first (more reliable on mobile)
|
||||
// Cast to any because TS dedicated worker scope doesn't know about registration
|
||||
const swReg = (self as any).registration;
|
||||
if ('serviceWorker' in self.navigator && swReg && swReg.showNotification) {
|
||||
swReg.showNotification("Time's Up!", {
|
||||
body: "Rest period finished",
|
||||
icon: '/assets/favicon.svg',
|
||||
vibrate: [200, 100, 200],
|
||||
tag: 'rest-timer',
|
||||
renotify: true
|
||||
});
|
||||
} else {
|
||||
// Fallback to standard Notification API
|
||||
// Cast options to any to allow 'renotify' which might be missing in strict lib
|
||||
new Notification("Time's Up!", {
|
||||
body: "Rest period finished",
|
||||
icon: '/assets/favicon.svg',
|
||||
tag: 'rest-timer',
|
||||
['renotify' as any]: true,
|
||||
} as NotificationOptions);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Worker notification failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
targetEndTime = null;
|
||||
}
|
||||
}, 200); // 200ms check for responsiveness
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PAUSE':
|
||||
case 'STOP':
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
targetEndTime = null;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export { };
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { playTimeUpSignal } from '../utils/audio';
|
||||
import { requestNotificationPermission, sendNotification, vibrateDevice } from '../utils/notifications';
|
||||
|
||||
export type TimerStatus = 'IDLE' | 'RUNNING' | 'PAUSED' | 'FINISHED';
|
||||
|
||||
@@ -63,35 +64,104 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
||||
const [status, setStatus] = useState<TimerStatus>(initialStatus);
|
||||
const [duration, setDuration] = useState(initialDuration);
|
||||
|
||||
|
||||
|
||||
// Worker reference
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
|
||||
// Initialize Worker
|
||||
useEffect(() => {
|
||||
// Create worker instance
|
||||
workerRef.current = new Worker(new URL('./timer.worker.ts', import.meta.url), { type: 'module' });
|
||||
|
||||
workerRef.current.onmessage = (e) => {
|
||||
const { type, timeLeft: workerTimeLeft } = e.data;
|
||||
|
||||
if (type === 'TICK') {
|
||||
if (document.hidden) {
|
||||
setTimeLeft(workerTimeLeft);
|
||||
}
|
||||
} else if (type === 'FINISHED') {
|
||||
// Worker says done.
|
||||
setStatus((prev) => {
|
||||
if (prev === 'FINISHED') return prev;
|
||||
|
||||
playTimeUpSignal();
|
||||
sendNotification("Time's Up!", "Rest period finished");
|
||||
vibrateDevice();
|
||||
|
||||
if (onFinish) onFinish();
|
||||
|
||||
// Cleanup RAF if it was running
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
endTimeRef.current = null;
|
||||
|
||||
return 'FINISHED';
|
||||
});
|
||||
setTimeLeft(0);
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
workerRef.current?.terminate();
|
||||
};
|
||||
}, [onFinish, duration]);
|
||||
|
||||
|
||||
// Recover worker if we restored a RUNNING state
|
||||
useEffect(() => {
|
||||
if (initialStatus === 'RUNNING' && savedState?.endTime) {
|
||||
if (workerRef.current) {
|
||||
workerRef.current.postMessage({
|
||||
type: 'START',
|
||||
payload: { endTime: savedState.endTime }
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const endTimeRef = useRef<number | null>(savedState?.endTime || null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const prevDefaultTimeRef = useRef(defaultTime);
|
||||
|
||||
// Tick function - defined before effects
|
||||
// Tick function - defined before effects (RAF version)
|
||||
const tick = useCallback(() => {
|
||||
if (!endTimeRef.current) return;
|
||||
const now = Date.now();
|
||||
const remaining = Math.max(0, Math.ceil((endTimeRef.current - now) / 1000));
|
||||
|
||||
// Only update state if it changed (to avoid extra renders, though React handles this)
|
||||
setTimeLeft(remaining);
|
||||
|
||||
if (remaining <= 0) {
|
||||
// Finished
|
||||
setStatus('FINISHED');
|
||||
playTimeUpSignal();
|
||||
if (onFinish) onFinish();
|
||||
sendNotification("Time's Up!", "Rest period finished");
|
||||
vibrateDevice();
|
||||
|
||||
if (onFinish) onFinish(); // Ensure this is only called once per finish
|
||||
|
||||
endTimeRef.current = null;
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
|
||||
endTimeRef.current = null; // Clear end time
|
||||
|
||||
// Auto-reset visuals after 3 seconds of "FINISHED" state?
|
||||
setTimeout(() => {
|
||||
setStatus(prev => prev === 'FINISHED' ? 'IDLE' : prev);
|
||||
setTimeLeft(prev => prev === 0 ? duration : prev);
|
||||
}, 3000);
|
||||
} else {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
}, [duration, onFinish]);
|
||||
|
||||
// Handle Auto-Reset when status becomes FINISHED (covers both active finish and restore-from-finished)
|
||||
useEffect(() => {
|
||||
if (status === 'FINISHED') {
|
||||
const timer = setTimeout(() => {
|
||||
setStatus(prev => prev === 'FINISHED' ? 'IDLE' : prev);
|
||||
setTimeLeft(prev => prev === 0 ? duration : prev);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [status, duration]);
|
||||
|
||||
// Save to localStorage whenever relevant state changes
|
||||
useEffect(() => {
|
||||
const stateToSave = {
|
||||
@@ -143,17 +213,32 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
||||
|
||||
// If starting from IDLE or PAUSED
|
||||
const targetSeconds = status === 'PAUSED' ? timeLeft : duration;
|
||||
endTimeRef.current = Date.now() + targetSeconds * 1000;
|
||||
const endTime = Date.now() + targetSeconds * 1000;
|
||||
endTimeRef.current = endTime;
|
||||
|
||||
setStatus('RUNNING');
|
||||
// Effect will trigger tick
|
||||
|
||||
// Request Permissions strictly on user interaction
|
||||
requestNotificationPermission();
|
||||
|
||||
// Start Worker
|
||||
if (workerRef.current) {
|
||||
workerRef.current.postMessage({
|
||||
type: 'START',
|
||||
payload: { endTime }
|
||||
});
|
||||
}
|
||||
}, [status, timeLeft, duration]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (status !== 'RUNNING') return;
|
||||
setStatus('PAUSED');
|
||||
// Effect calls cancelAnimationFrame
|
||||
|
||||
endTimeRef.current = null;
|
||||
|
||||
if (workerRef.current) {
|
||||
workerRef.current.postMessage({ type: 'PAUSE' });
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const reset = useCallback((newDuration?: number) => {
|
||||
@@ -161,8 +246,12 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
||||
setDuration(nextDuration);
|
||||
setTimeLeft(nextDuration);
|
||||
setStatus('IDLE');
|
||||
|
||||
endTimeRef.current = null;
|
||||
// Effect calls cancelAnimationFrame (since status becomes IDLE)
|
||||
|
||||
if (workerRef.current) {
|
||||
workerRef.current.postMessage({ type: 'STOP' });
|
||||
}
|
||||
}, [duration]);
|
||||
|
||||
const addTime = useCallback((seconds: number) => {
|
||||
@@ -173,7 +262,15 @@ export const useRestTimer = ({ defaultTime, onFinish }: UseRestTimerProps) => {
|
||||
// Add to current target
|
||||
if (endTimeRef.current) {
|
||||
endTimeRef.current += seconds * 1000;
|
||||
// Force immediate update to avoid flicker
|
||||
// Update Worker
|
||||
if (workerRef.current) {
|
||||
workerRef.current.postMessage({
|
||||
type: 'START',
|
||||
payload: { endTime: endTimeRef.current }
|
||||
});
|
||||
}
|
||||
|
||||
// Force immediate update locally to avoid flicker
|
||||
const now = Date.now();
|
||||
setTimeLeft(Math.max(0, Math.ceil((endTimeRef.current - now) / 1000)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user