Test / static /script.js
Kexin-251202's picture
Upload 3 files
dd46120 verified
// ================ PAGE NAVIGATION ================
const pages = {
'page-a': document.getElementById('page-a'),
'page-b': document.getElementById('page-b'),
'page-c': document.getElementById('page-c'), // Achievement
'page-d': document.getElementById('page-d'), // Records
'page-e': document.getElementById('page-e'), // Customise
'page-f': document.getElementById('page-f') // Help
};
function showPage(pageId) {
for (const key in pages) {
pages[key].classList.add('hidden');
}
if (pages[pageId]) {
pages[pageId].classList.remove('hidden');
}
}
// ================ VIDEO MANAGER CLASS ================
class VideoManager {
constructor() {
this.videoElement = null;
this.canvasElement = null;
this.ctx = null;
this.captureCanvas = null;
this.captureCtx = null;
this.stream = null;
this.ws = null;
this.isStreaming = false;
this.sessionId = null;
this.frameRate = 30;
this.frameInterval = null;
this.renderLoopId = null;
// Status smoothing for stable display
this.currentStatus = false; // Default: not focused
this.previousStatus = false; // Track previous status to detect changes
this.statusBuffer = []; // Buffer for last N frames
this.bufferSize = 5; // Number of frames to average (smaller = more responsive)
// Latest detection data for rendering
this.latestDetectionData = null;
this.lastConfidence = 0;
this.detectionHoldMs = 30;
}
async initCamera() {
try {
this.stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 640 },
height: { ideal: 480 },
facingMode: 'user'
}
});
this.videoElement = document.createElement('video');
this.videoElement.srcObject = this.stream;
this.videoElement.autoplay = true;
this.videoElement.playsInline = true;
this.canvasElement = document.createElement('canvas');
this.canvasElement.width = 640;
this.canvasElement.height = 480;
this.ctx = this.canvasElement.getContext('2d');
this.captureCanvas = document.createElement('canvas');
this.captureCanvas.width = 640;
this.captureCanvas.height = 480;
this.captureCtx = this.captureCanvas.getContext('2d');
const displayArea = document.getElementById('display-area');
displayArea.innerHTML = '';
displayArea.appendChild(this.canvasElement);
await this.videoElement.play();
this.startRenderLoop();
return true;
} catch (error) {
console.error('Camera init error:', error);
throw error;
}
}
connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/video`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.startSession();
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleServerMessage(data);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.ws.onclose = () => {
console.log('WebSocket closed');
};
}
startStreaming() {
this.isStreaming = true;
this.connectWebSocket();
this.frameInterval = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.captureAndSendFrame();
}
}, 1000 / this.frameRate);
}
captureAndSendFrame() {
if (!this.videoElement || !this.captureCanvas || !this.captureCtx) return;
this.captureCtx.drawImage(this.videoElement, 0, 0, 640, 480);
const imageData = this.captureCanvas.toDataURL('image/jpeg', 0.8);
const base64Data = imageData.split(',')[1];
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'frame',
image: base64Data
}));
}
}
handleServerMessage(data) {
switch (data.type) {
case 'detection':
// Update status with smoothing
this.updateStatus(data.focused);
// Render with smoothed status
this.renderDetections(data);
// Update timeline and notifications with smoothed status
timeline.addEvent(this.currentStatus);
checkDistraction(this.currentStatus);
break;
case 'session_started':
this.sessionId = data.session_id;
console.log('Session started:', this.sessionId);
break;
case 'session_ended':
console.log('Session ended:', data.summary);
showSessionSummary(data.summary);
break;
case 'ack':
// Frame acknowledged but not processed
break;
case 'error':
console.error('Server error:', data.message);
break;
}
}
updateStatus(newFocused) {
// Add to buffer
this.statusBuffer.push(newFocused);
// Keep buffer size limited
if (this.statusBuffer.length > this.bufferSize) {
this.statusBuffer.shift();
}
// Don't update status until buffer is full (prevents initial flickering)
if (this.statusBuffer.length < this.bufferSize) {
return false; // Status hasn't changed
}
// Calculate majority vote with moderate thresholds for better responsiveness
const focusedCount = this.statusBuffer.filter(f => f).length;
const focusedRatio = focusedCount / this.statusBuffer.length;
// Store previous status
this.previousStatus = this.currentStatus;
// Moderate thresholds: quicker to change, still avoids rapid flipping
// For 8 frames: need 6+ focused to become FOCUSED, or 6+ not focused to become NOT FOCUSED
if (focusedRatio >= 0.75) {
this.currentStatus = true;
} else if (focusedRatio <= 0.25) {
this.currentStatus = false;
}
// Between 0.25-0.75: keep current status (hysteresis to avoid jitter)
// Log only when status actually changes
const statusChanged = this.currentStatus !== this.previousStatus;
if (statusChanged) {
console.log(`Status changed: ${this.previousStatus ? 'FOCUSED' : 'NOT FOCUSED'} -> ${this.currentStatus ? 'FOCUSED' : 'NOT FOCUSED'} (ratio: ${focusedRatio.toFixed(2)})`);
}
// Return whether status changed
return statusChanged;
}
renderDetections(data) {
this.latestDetectionData = {
detections: data.detections || [],
confidence: data.confidence || 0,
focused: data.focused,
timestamp: performance.now()
};
this.lastConfidence = data.confidence || 0;
}
startRenderLoop() {
if (this.renderLoopId) return;
const render = () => {
if (this.videoElement && this.ctx) {
this.ctx.drawImage(this.videoElement, 0, 0, 640, 480);
const now = performance.now();
const latest = this.latestDetectionData;
const hasFresh = latest && (now - latest.timestamp) <= this.detectionHoldMs;
// Draw detection boxes using last known data (prevents flicker)
if (hasFresh && latest.detections.length > 0) {
latest.detections.forEach(det => {
const [x1, y1, x2, y2] = det.bbox;
this.ctx.strokeStyle = this.currentStatus ? '#00FF00' : '#FF0000';
this.ctx.lineWidth = 3;
this.ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
this.ctx.fillStyle = this.currentStatus ? '#00FF00' : '#FF0000';
this.ctx.font = '16px Nunito';
const label = `${det.class_name} ${(det.confidence * 100).toFixed(1)}%`;
this.ctx.fillText(label, x1, y1 - 5);
});
}
const statusText = this.currentStatus ? 'FOCUSED' : 'NOT FOCUSED';
this.ctx.fillStyle = this.currentStatus ? '#00FF00' : '#FF0000';
this.ctx.font = 'bold 24px Nunito';
this.ctx.fillText(statusText, 10, 30);
this.ctx.font = '16px Nunito';
this.ctx.fillText(`Confidence: ${(this.lastConfidence * 100).toFixed(1)}%`, 10, 55);
}
this.renderLoopId = requestAnimationFrame(render);
};
this.renderLoopId = requestAnimationFrame(render);
}
stopRenderLoop() {
if (this.renderLoopId) {
cancelAnimationFrame(this.renderLoopId);
this.renderLoopId = null;
}
}
startSession() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'start_session' }));
}
}
stopStreaming() {
this.isStreaming = false;
this.stopRenderLoop();
if (this.frameInterval) {
clearInterval(this.frameInterval);
this.frameInterval = null;
}
if (this.ws) {
this.ws.send(JSON.stringify({ type: 'end_session' }));
this.ws.close();
this.ws = null;
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
if (this.canvasElement && this.ctx) {
this.ctx.clearRect(0, 0, 640, 480);
}
// Reset status
this.currentStatus = false;
this.statusBuffer = [];
this.latestDetectionData = null;
this.lastConfidence = 0;
}
setFrameRate(rate) {
this.frameRate = Math.max(1, Math.min(60, rate));
if (this.isStreaming && this.frameInterval) {
clearInterval(this.frameInterval);
this.frameInterval = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.captureAndSendFrame();
}
}, 1000 / this.frameRate);
}
}
}
// ================ TIMELINE MANAGER CLASS ================
class TimelineManager {
constructor(maxEvents = 60) {
this.events = [];
this.maxEvents = maxEvents;
this.container = document.getElementById('timeline-visuals');
}
addEvent(isFocused) {
const timestamp = Date.now();
this.events.push({ timestamp, isFocused });
if (this.events.length > this.maxEvents) {
this.events.shift();
}
this.render();
}
render() {
if (!this.container) return;
this.container.innerHTML = '';
this.events.forEach((event, index) => {
const block = document.createElement('div');
block.className = 'timeline-block';
block.style.backgroundColor = event.isFocused ? '#00FF00' : '#FF0000';
block.style.width = '10px';
block.style.height = '20px';
block.style.display = 'inline-block';
block.style.marginRight = '2px';
block.title = event.isFocused ? 'Focused' : 'Distracted';
this.container.appendChild(block);
});
}
clear() {
this.events = [];
this.render();
}
}
// ================ NOTIFICATION SYSTEM ================
let distractionStartTime = null;
let notificationTimeout = null;
let currentSettings = null;
async function loadCurrentSettings() {
try {
const response = await fetch('/api/settings');
currentSettings = await response.json();
} catch (error) {
console.error('Failed to load settings:', error);
currentSettings = {
notification_enabled: true,
notification_threshold: 30
};
}
}
function checkDistraction(isFocused) {
if (!currentSettings || !currentSettings.notification_enabled) return;
if (!isFocused) {
if (!distractionStartTime) {
distractionStartTime = Date.now();
}
const distractionDuration = (Date.now() - distractionStartTime) / 1000;
if (distractionDuration >= currentSettings.notification_threshold && !notificationTimeout) {
sendNotification('Focus Guard Alert', 'You seem distracted. Time to refocus!');
notificationTimeout = setTimeout(() => {
notificationTimeout = null;
}, 60000);
}
} else {
distractionStartTime = null;
}
}
async function sendNotification(title, body) {
if ('Notification' in window) {
if (Notification.permission === 'granted') {
new Notification(title, { body });
} else if (Notification.permission !== 'denied') {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
new Notification(title, { body });
}
}
}
}
// ================ SESSION SUMMARY MODAL ================
function showSessionSummary(summary) {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content">
<h2>Session Complete!</h2>
<div class="summary-stats">
<div class="summary-item">
<span class="summary-label">Duration:</span>
<span class="summary-value">${formatDuration(summary.duration_seconds)}</span>
</div>
<div class="summary-item">
<span class="summary-label">Focus Score:</span>
<span class="summary-value">${(summary.focus_score * 100).toFixed(1)}%</span>
</div>
<div class="summary-item">
<span class="summary-label">Total Frames:</span>
<span class="summary-value">${summary.total_frames}</span>
</div>
<div class="summary-item">
<span class="summary-label">Focused Frames:</span>
<span class="summary-value">${summary.focused_frames}</span>
</div>
</div>
<button class="btn-main" onclick="closeModal()">Close</button>
</div>
`;
document.body.appendChild(modal);
}
function closeModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) {
modal.remove();
}
}
function formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
} else if (minutes > 0) {
return `${minutes}m ${secs}s`;
} else {
return `${secs}s`;
}
}
// ================ GLOBAL INSTANCES ================
const videoManager = new VideoManager();
const timeline = new TimelineManager();
// ================ EVENT LISTENERS ================
// Page navigation
document.getElementById('menu-start').addEventListener('click', () => showPage('page-b'));
document.getElementById('menu-achievement').addEventListener('click', () => {
showPage('page-c');
loadAchievements();
});
document.getElementById('menu-records').addEventListener('click', () => {
showPage('page-d');
loadRecords('today');
});
document.getElementById('menu-customise').addEventListener('click', () => {
showPage('page-e');
loadSettings();
});
document.getElementById('menu-help').addEventListener('click', () => showPage('page-f'));
document.getElementById('start-button').addEventListener('click', () => showPage('page-b'));
// Page B controls
document.getElementById('btn-cam-start').addEventListener('click', async () => {
try {
await videoManager.initCamera();
videoManager.startStreaming();
timeline.clear();
await loadCurrentSettings();
} catch (error) {
console.error('Failed to start camera:', error);
alert('Camera access denied. Please allow camera permissions and ensure you are using HTTPS or localhost.');
}
});
document.getElementById('btn-cam-stop').addEventListener('click', () => {
videoManager.stopStreaming();
});
document.getElementById('btn-floating').addEventListener('click', () => {
alert('Floating window feature coming soon!');
});
document.getElementById('btn-models').addEventListener('click', () => {
alert('Model selection feature coming soon!');
});
// Frame control
const frameSlider = document.getElementById('frame-slider');
const frameInput = document.getElementById('frame-input');
frameSlider.addEventListener('input', (e) => {
const rate = parseInt(e.target.value);
frameInput.value = rate;
videoManager.setFrameRate(rate);
});
frameInput.addEventListener('input', (e) => {
const rate = parseInt(e.target.value);
frameSlider.value = rate;
videoManager.setFrameRate(rate);
});
// ================ ACHIEVEMENT PAGE ================
async function loadAchievements() {
try {
const response = await fetch('/api/stats/summary');
const stats = await response.json();
document.getElementById('total-sessions').textContent = stats.total_sessions;
document.getElementById('total-hours').textContent =
(stats.total_focus_time / 3600).toFixed(1) + 'h';
document.getElementById('avg-focus').textContent =
(stats.avg_focus_score * 100).toFixed(1) + '%';
document.getElementById('current-streak').textContent = stats.streak_days;
loadBadges(stats);
} catch (error) {
console.error('Failed to load achievements:', error);
}
}
function loadBadges(stats) {
const badges = [
{ name: 'First Session', condition: stats.total_sessions >= 1, icon: '' },
{ name: '10 Sessions', condition: stats.total_sessions >= 10, icon: '' },
{ name: '50 Sessions', condition: stats.total_sessions >= 50, icon: '' },
{ name: '10 Hour Focus', condition: stats.total_focus_time >= 36000, icon: '' },
{ name: '7 Day Streak', condition: stats.streak_days >= 7, icon: '' },
{ name: '90% Avg Focus', condition: stats.avg_focus_score >= 0.9, icon: '' }
];
const container = document.getElementById('badges-container');
container.innerHTML = '';
badges.forEach(badge => {
const badgeEl = document.createElement('div');
badgeEl.className = 'badge ' + (badge.condition ? 'earned' : 'locked');
badgeEl.innerHTML = `
<div class="badge-icon">${badge.icon}</div>
<div class="badge-name">${badge.name}</div>
`;
container.appendChild(badgeEl);
});
}
// ================ RECORDS PAGE ================
async function loadRecords(filter = 'today') {
try {
const response = await fetch(`/api/sessions?filter=${filter}`);
const sessions = await response.json();
renderSessionsTable(sessions);
renderChart(sessions);
} catch (error) {
console.error('Failed to load records:', error);
}
}
function renderSessionsTable(sessions) {
const tbody = document.getElementById('sessions-tbody');
tbody.innerHTML = '';
sessions.forEach(session => {
const row = document.createElement('tr');
const date = new Date(session.start_time).toLocaleString();
const duration = formatDuration(session.duration_seconds);
const score = (session.focus_score * 100).toFixed(1) + '%';
row.innerHTML = `
<td>${date}</td>
<td>${duration}</td>
<td>${score}</td>
<td><button class="btn-view" onclick="viewSessionDetails(${session.id})">View</button></td>
`;
tbody.appendChild(row);
});
if (sessions.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="4" style="text-align: center;">No sessions found</td>';
tbody.appendChild(row);
}
}
function renderChart(sessions) {
const canvas = document.getElementById('focus-chart');
const ctx = canvas.getContext('2d');
canvas.width = 800;
canvas.height = 300;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (sessions.length === 0) {
ctx.fillStyle = '#888';
ctx.font = '20px Nunito';
ctx.fillText('No data available', canvas.width / 2 - 80, canvas.height / 2);
return;
}
const barWidth = Math.min((canvas.width - 40) / sessions.length - 10, 80);
const maxScore = 1.0;
sessions.forEach((session, index) => {
const x = index * (barWidth + 10) + 20;
const barHeight = (session.focus_score / maxScore) * (canvas.height - 60);
const y = canvas.height - barHeight - 30;
ctx.fillStyle = session.focus_score > 0.7 ? '#28a745' :
session.focus_score > 0.4 ? '#ffc107' : '#dc3545';
ctx.fillRect(x, y, barWidth, barHeight);
ctx.fillStyle = '#333';
ctx.font = '12px Nunito';
const scoreText = (session.focus_score * 100).toFixed(0) + '%';
ctx.fillText(scoreText, x + barWidth / 2 - 15, y - 5);
});
}
function viewSessionDetails(sessionId) {
alert(`Session details for ID ${sessionId} - Feature coming soon!`);
}
// Filter buttons
document.getElementById('filter-today').addEventListener('click', () => {
setActiveFilter('filter-today');
loadRecords('today');
});
document.getElementById('filter-week').addEventListener('click', () => {
setActiveFilter('filter-week');
loadRecords('week');
});
document.getElementById('filter-month').addEventListener('click', () => {
setActiveFilter('filter-month');
loadRecords('month');
});
document.getElementById('filter-all').addEventListener('click', () => {
setActiveFilter('filter-all');
loadRecords('all');
});
function setActiveFilter(activeId) {
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.remove('active');
});
document.getElementById(activeId).classList.add('active');
}
// ================ SETTINGS PAGE ================
async function loadSettings() {
try {
const response = await fetch('/api/settings');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const settings = await response.json();
console.log('Loaded settings:', settings);
// Apply settings with fallback to defaults
document.getElementById('sensitivity-slider').value = settings.sensitivity || 6;
document.getElementById('sensitivity-value').textContent = settings.sensitivity || 6;
document.getElementById('default-framerate').value = settings.frame_rate || 30;
document.getElementById('framerate-value').textContent = settings.frame_rate || 30;
document.getElementById('enable-notifications').checked = settings.notification_enabled !== false;
document.getElementById('notification-threshold').value = settings.notification_threshold || 30;
} catch (error) {
console.error('Failed to load settings:', error);
alert('Failed to load settings: ' + error.message);
}
}
async function saveSettings() {
const settings = {
sensitivity: parseInt(document.getElementById('sensitivity-slider').value),
frame_rate: parseInt(document.getElementById('default-framerate').value),
notification_enabled: document.getElementById('enable-notifications').checked,
notification_threshold: parseInt(document.getElementById('notification-threshold').value)
};
console.log('Saving settings:', settings);
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
if (response.ok) {
const result = await response.json();
console.log('Settings saved:', result);
alert('Settings saved successfully!');
await loadCurrentSettings();
} else {
const error = await response.text();
console.error('Save failed with status:', response.status, error);
alert(`Failed to save settings: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error('Failed to save settings:', error);
alert('Failed to save settings: ' + error.message);
}
}
// Settings UI handlers
document.getElementById('sensitivity-slider').addEventListener('input', (e) => {
document.getElementById('sensitivity-value').textContent = e.target.value;
});
document.getElementById('default-framerate').addEventListener('input', (e) => {
document.getElementById('framerate-value').textContent = e.target.value;
});
document.getElementById('save-settings').addEventListener('click', saveSettings);
document.getElementById('export-data').addEventListener('click', async () => {
try {
const response = await fetch('/api/sessions?filter=all');
const sessions = await response.json();
const dataStr = JSON.stringify(sessions, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `focus-guard-data-${new Date().toISOString().split('T')[0]}.json`;
link.click();
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to export data:', error);
alert('Failed to export data');
}
});
document.getElementById('clear-history').addEventListener('click', async () => {
if (confirm('Are you sure you want to clear all history? This cannot be undone.')) {
alert('Clear history feature requires backend implementation');
}
});
// ================ INITIALIZATION ================
// Request notification permission on load
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
// Load settings on startup
loadCurrentSettings();
console.log(' Focus Guard initialized');