const CONFIG = { API_URL: window.location.origin, WEBSOCKET_URL: (window.location.protocol === "https:" ? "wss://" : "ws://") + window.location.host + "/ws/video_feed", TRIGGER_AUDIO_URL: "/trigger_audio_analysis", GET_AUDIO_RESULT_URL: "/get_latest_audio_result", POLLING_INTERVAL_MS: 30000, DEFAULT_VIDEO_URLS: [ "./static/demo/3_usa.mp4", "./static/demo/5_usa.mp4", "./static/demo/7_usa.mp4", "./static/demo/6_usa.mp4", ], DEFAULT_AUDIO_URL: "./static/demo/audio.m4a", }; // --- Global State --- const state = { show_detected: true, show_density: false, show_inactive: false, isStreaming: false, cameraWebSockets: [null, null, null, null], audioPollInterval: null, cameraUrls: [...CONFIG.DEFAULT_VIDEO_URLS], audioUrl: CONFIG.DEFAULT_AUDIO_URL, cameraStats: Array(4).fill().map(() => ({ detected: 0, inactive: 0, dense_areas: 0 })), lastVocalization: null, isAnalyzingAudio: false, lastAnalyzedAudioUrl: null, lastAnalysisTimestamp: null, lastInactiveAlert: {}, INACTIVE_THRESHOLD: 0.15 //percentage }; // --- DOM Elements --- const $ = (selector) => document.querySelector(selector); const DOMElements = { // Cache datetime: $('#datetime'), canvases: Array.from({ length: 4 }, (_, i) => $(`#video-canvas-${i}`)), contexts: [], toggleControls: $('#toggle-controls'), settingsButton: $('#settings-button'), detectedCount: $('#detected-count'), densityCount: $('#density-count'), inactiveCount: $('#inactive-count'), vocalizationContent: $('#vocalization-content'), systemLog: $('#system-log'), settingsModal: $('#settings-modal'), saveSettingsBtn: $('#save-settings-btn'), stopAllStreamsBtn: $('#stop-all-streams-btn'), camUrlInputs: Array.from({ length: 4 }, (_, i) => $(`#cam${i+1}-url`)), audioUrlInput: $('#audio-url'), exportModal: $('#export-modal'), openExportModalBtn: $('#open-export-modal-btn'), startDateInput: $('#start-date'), endDateInput: $('#end-date'), cameraSelect: $('#camera-select'), downloadCsvBtn: $('#download-csv-btn'), }; // Init canvas contexts DOMElements.contexts = DOMElements.canvases.map(canvas => canvas ? canvas.getContext('2d') : null ); // --- Utils Functions --- function updateDateTime() { if (!DOMElements.datetime) return; DOMElements.datetime.textContent = new Date().toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'short' }); } function addLog(message, type = 'info') { if (!DOMElements.systemLog) return; const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false }); const typeClass = { info: 'text-slate-500', warning: 'text-amber-600', danger: 'text-red-600 font-semibold' }[type] || 'text-slate-500'; const logEntry = document.createElement('p'); logEntry.className = typeClass; logEntry.innerHTML = `${timestamp} ${message}`; DOMElements.systemLog.prepend(logEntry); if (DOMElements.systemLog.children.length > 30) { DOMElements.systemLog.removeChild(DOMElements.systemLog.lastChild); } } function updateToggleButtons() { const buttons = DOMElements.toggleControls.querySelectorAll('button'); const activeColors = { show_detected: '#22c55e', // Green show_density: '#f97316', // Orange show_inactive: '#ef4444' // Red }; buttons.forEach(button => { const control = button.dataset.control; const isActive = state[control]; button.classList.toggle('active', isActive); button.setAttribute('aria-pressed', isActive); if (isActive) { const color = activeColors[control]; button.style.backgroundColor = color; button.style.borderColor = color; button.style.color = '#ffffff'; } else { button.style.backgroundColor = ''; button.style.borderColor = ''; button.style.color = ''; } }); } function updateDailyAnalysisUI() { const totals = state.cameraStats.reduce((acc, stats) => ({ detected: acc.detected + (stats.detected || 0), inactive: acc.inactive + (stats.inactive || 0), denseAreas: acc.denseAreas + (stats.dense_areas || 0) }), { detected: 0, inactive: 0, denseAreas: 0 }); if (DOMElements.detectedCount) DOMElements.detectedCount.textContent = totals.detected; if (DOMElements.inactiveCount) DOMElements.inactiveCount.textContent = totals.inactive; if (DOMElements.densityCount) DOMElements.densityCount.textContent = totals.denseAreas; } function displayAudioResults(data) { if (!DOMElements.vocalizationContent) return; if (!data) { DOMElements.vocalizationContent.innerHTML = `

No response from server.

`; return; } if (data.status === "analyzing") { DOMElements.vocalizationContent.innerHTML = `

Vocalization analysis in progress...

`; return; } if (data.status === "no_data" || data.prediction === null) { DOMElements.vocalizationContent.innerHTML = `

Waiting for vocalization analysis to run...

`; return; } if (data.prediction === "Error") { DOMElements.vocalizationContent.innerHTML = `

Error during audio analysis.

`; return; } const probabilities = data.probabilities || {}; const statusMap = { 'Healthy': { text: 'Healthy', color: 'green' }, 'Unhealthy': { text: 'Unhealthy', color: 'red' }, 'Noise': { text: 'Noise', color: 'amber' }, }; const dominantStatus = statusMap[data.prediction] || { text: data.prediction, color: 'slate' }; if (data.prediction === "Unhealthy" && state.lastVocalization !== "Unhealthy") { addLog("Vocalization Status: Unhealthy Detected", "danger"); state.lastVocalization = "Unhealthy"; } else if (data.prediction === "Healthy") { state.lastVocalization = "Healthy"; } let barsHtml = ''; for (const [key, value] of Object.entries(probabilities)) { const status = statusMap[key] || { text: key, color: 'slate' }; const percentage = (value * 100).toFixed(1); barsHtml += `
${status.text} ${percentage}%
`; } DOMElements.vocalizationContent.innerHTML = `
Dominant Status: ${dominantStatus.text}
${barsHtml}
`; } // --- Modal function --- function openModal(modalEl) { modalEl.classList.add('show'); modalEl.setAttribute('aria-hidden', 'false'); document.body.classList.add('overflow-hidden'); } function closeModal(modalEl) { modalEl.classList.remove('show'); modalEl.setAttribute('aria-hidden', 'true'); document.body.classList.remove('overflow-hidden'); } function populateSettingsModal() { DOMElements.camUrlInputs.forEach((input, i) => { if (input) input.value = state.cameraUrls[i] || ''; }); if (DOMElements.audioUrlInput) { DOMElements.audioUrlInput.value = state.audioUrl || ''; } } // Make Local Storage for url when change function loadSettingsFromStorage() { const savedCameraUrls = localStorage.getItem('chickSenseCameraUrls'); const savedAudioUrl = localStorage.getItem('chickSenseAudioUrl'); if (savedCameraUrls) { try { state.cameraUrls = JSON.parse(savedCameraUrls); } catch (e) { console.error("Failed to parse saved camera URLs, using defaults.", e); state.cameraUrls = [...CONFIG.DEFAULT_VIDEO_URLS]; } } if (savedAudioUrl) { state.audioUrl = savedAudioUrl; } addLog('Settings loaded from browser storage', 'info'); } // --- Websocket Streaming functions --- function connectWebSocket(cameraIndex) { const videoUrl = state.cameraUrls[cameraIndex]; if (!videoUrl) { console.warn(`No URL for Camera ${cameraIndex + 1}, skipping connection.`); return; } if (state.cameraWebSockets[cameraIndex]) { state.cameraWebSockets[cameraIndex].close(); } try { const ws = new WebSocket(CONFIG.WEBSOCKET_URL); ws.binaryType = "blob"; state.cameraWebSockets[cameraIndex] = ws; ws.onopen = () => { addLog(`Connecting to Camera ${cameraIndex + 1}`, 'info'); ws.send(JSON.stringify({ type: 'start_stream', video_url: videoUrl, audio_url: state.audioUrl, show_detected: state.show_detected, show_density: state.show_density, show_inactive: state.show_inactive, camera_id: cameraIndex + 1 })); state.isStreaming = true; }; ws.onmessage = (event) => { if (event.data instanceof Blob) { handleBlobMessage(event.data, cameraIndex); } else if (typeof event.data === 'string') { handleTextMessage(event.data, cameraIndex); } }; ws.onclose = () => { addLog(`Camera ${cameraIndex + 1} disconnected.`, 'warning'); const ctx = DOMElements.contexts[cameraIndex]; if (ctx) ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); state.cameraWebSockets[cameraIndex] = null; state.cameraStats[cameraIndex] = { detected: 0, inactive: 0, dense_areas: 0 }; updateDailyAnalysisUI(); state.isStreaming = state.cameraWebSockets.some(ws => ws !== null); if (!state.isStreaming) { addLog("All streams disconnected.", 'danger'); } }; ws.onerror = (error) => { console.error(`WebSocket Error for Camera ${cameraIndex + 1}:`, error); addLog(`Connection failed for Camera ${cameraIndex + 1}.`, 'danger'); }; } catch (error) { console.error(`Failed to create WebSocket for Camera ${cameraIndex + 1}:`, error); addLog(`Failed to connect to Camera ${cameraIndex + 1}.`, 'danger'); } } function handleBlobMessage(blob, cameraIndex) { const imageUrl = URL.createObjectURL(blob); const img = new Image(); img.onload = () => { const ctx = DOMElements.contexts[cameraIndex]; if (ctx) { ctx.canvas.width = img.width; ctx.canvas.height = img.height; ctx.drawImage(img, 0, 0); } URL.revokeObjectURL(imageUrl); }; img.onerror = () => { console.error(`Failed to load image for Camera ${cameraIndex + 1}`); URL.revokeObjectURL(imageUrl); }; img.src = imageUrl; } function handleTextMessage(data, cameraIndex) { try { const parsedData = JSON.parse(data); if (parsedData.type === 'stats') { const stats = { detected: Number(parsedData.detected || 0), inactive: Number(parsedData.inactive || 0), dense_areas: Number(parsedData.dense_areas || 0), }; state.cameraStats[cameraIndex] = stats; updateDailyAnalysisUI(); if (stats.detected > 0) { const inactiveRatio = stats.inactive / stats.detected; const percent = Math.round(inactiveRatio * 100); const cameraId = cameraIndex + 1; const isAboveThreshold = inactiveRatio > state.INACTIVE_THRESHOLD; const hasAlerted = state.lastInactiveAlert[cameraId]; if (isAboveThreshold && !hasAlerted){ const message = `Camera ${cameraId}: Inactive chicken percentage is high (${percent}%) `; addLog(message, "danger"); state.lastInactiveAlert[cameraId] = true; } else if (!isAboveThreshold && hasAlerted){ state.lastInactiveAlert[cameraId] = false; } } }else if (parsedData.type === 'status') { const msg = parsedData.message; if (msg === "Display settings updated") { return; } addLog(`[Global] ${msg}`, 'info'); } } catch (e) { console.error("Error parsing WebSocket JSON message:", e, data); } } function startAllStreams() { addLog("Attempting to start all configured streams...", 'info'); for (let i = 0; i < 4; i++) { setTimeout(() => connectWebSocket(i), i * 100); } if (!state.audioPollInterval) { fetchLatestAudioResult(); state.audioPollInterval = setInterval( fetchLatestAudioResult, CONFIG.POLLING_INTERVAL_MS ); } } function stopAllStreams() { addLog("Stopping all streams...", 'warning'); state.cameraWebSockets.forEach((ws, index) => { if (ws) { ws.close(); state.cameraWebSockets[index] = null; } }); if (state.audioPollInterval) { clearInterval(state.audioPollInterval); state.audioPollInterval = null; } displayAudioResults(null); state.isStreaming = false; } async function fetchLatestAudioResult() { try { const response = await fetch(`${CONFIG.API_URL}${CONFIG.GET_AUDIO_RESULT_URL}`); if (!response.ok) { if(response.status === 404){ return; }throw new Error(`Server status ${response.status}`) } const audioData = await response.json(); const resultKey = JSON.stringify(audioData); const wasAnalyzing = state.isAnalyzingAudio; if (state.lastAudioResultKey && state.lastAudioResultKey === resultKey){ return; } state.lastAudioResultKey = resultKey; if (wasAnalyzing){ addLog("Vocalization results updated", "info"); state.isAnalyzingAudio = false; state.lastAnalysisTimestamp = new Date(); }else{ addLog("Vocalization results available", "info"); } displayAudioResults(audioData); } catch (error) { console.error("Error fetching audio result:", error); if (state.isAnalyzingAudio){ addLog("Audio analysis failed", "danger"); state.isAnalyzingAudio = false; } } } async function downloadCSV() { const start = DOMElements.startDateInput.value; const end = DOMElements.endDateInput.value; const cameraId = DOMElements.cameraSelect.value; if (!start || !end) { addLog("Please select a start and end date for export.", 'warning'); return; } try { const url = new URL(`${CONFIG.API_URL}/metrics/export`); url.searchParams.set('start', start); url.searchParams.set('end', end); if (cameraId) url.searchParams.set('camera_id', cameraId); addLog(`Exporting CSV for ${start} to ${end}...`, 'info'); const response = await fetch(url.toString()); if (!response.ok) { throw new Error(`Export failed with status ${response.status}`); } const blob = await response.blob(); const downloadUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = downloadUrl; a.download = `metrics_${start}_to_${end}${cameraId ? `_cam${cameraId}` : ''}.csv`; document.body.appendChild(a); a.click(); // Clean up setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(downloadUrl); }, 100); addLog("CSV download started.", 'info'); closeModal(DOMElements.exportModal); } catch (error) { console.error("CSV Download Error:", error); addLog(error.message, 'danger'); } } // -- Event Handlers -- function handleToggleControlClick(e) { const button = e.target.closest('button'); if (!button) return; const control = button.dataset.control; if (!control) return; state[control] = !state[control]; updateToggleButtons(); const labelMap = { show_detected: 'Detection', show_density: 'Density', show_inactive: 'Inactivity' }; const displayName = labelMap[control] || control; const status = state[control] ? 'enabled' : 'disabled'; addLog(`View updated: ${displayName} ${status}`, 'info'); const payload = JSON.stringify({ type: 'display_settings_update', show_detected: state.show_detected, show_density: state.show_density, show_inactive: state.show_inactive }); state.cameraWebSockets.forEach(ws => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(payload); } }); } function handleSettingsButtonClick() { addLog('Tip: Double-click the gear icon to open camera settings.', 'info'); } function handleSettingsButtonDoubleClick() { populateSettingsModal(); openModal(DOMElements.settingsModal); } function handleSaveSettingsClick() { state.cameraUrls = DOMElements.camUrlInputs.map(input => input ? input.value.trim() : '' ); state.audioUrl = DOMElements.audioUrlInput ? DOMElements.audioUrlInput.value.trim() : ''; try { localStorage.setItem('chickSenseCameraUrls', JSON.stringify(state.cameraUrls)); localStorage.setItem('chickSenseAudioUrl', state.audioUrl); addLog('Settings saved to browser storage.', 'info'); } catch (e) { console.error("Failed to save settings to local storage:", e); addLog('Could not save settings.', 'danger'); } addLog('Settings saved. Restarting streams...', 'info'); closeModal(DOMElements.settingsModal); stopAllStreams(); setTimeout(startAllStreams, 500); } function handleStopStreamsClick() { stopAllStreams(); closeModal(DOMElements.settingsModal); } function handleExportButtonClick() { addLog('Tip: Double-click "Export CSV" to export metrics to a CSV file.', 'info'); } function handleExportButtonDoubleClick() { openModal(DOMElements.exportModal); } function handleModalCloseClick(e) { const modal = e.currentTarget; if (e.target.classList.contains('modal-backdrop') || e.target.closest('.modal-close-btn')) { closeModal(modal); } } function handleEscapeKey(e) { if (e.key === 'Escape') { document.querySelectorAll('.modal.show').forEach(closeModal); } } // -- Add Event Listener -- function setupEventListeners() { if (DOMElements.toggleControls) { DOMElements.toggleControls.addEventListener('click', handleToggleControlClick); } if (DOMElements.settingsButton) { DOMElements.settingsButton.addEventListener('click', handleSettingsButtonClick); DOMElements.settingsButton.addEventListener('dblclick', handleSettingsButtonDoubleClick); } if (DOMElements.saveSettingsBtn) { DOMElements.saveSettingsBtn.addEventListener('click', handleSaveSettingsClick); } if (DOMElements.stopAllStreamsBtn) { DOMElements.stopAllStreamsBtn.addEventListener('click', handleStopStreamsClick); } if (DOMElements.openExportModalBtn) { DOMElements.openExportModalBtn.addEventListener('click', handleExportButtonClick); DOMElements.openExportModalBtn.addEventListener('dblclick', handleExportButtonDoubleClick); } if (DOMElements.downloadCsvBtn) { DOMElements.downloadCsvBtn.addEventListener('click', downloadCSV); } document.querySelectorAll('.modal').forEach(modal => { modal.addEventListener('click', handleModalCloseClick); }); window.addEventListener('keydown', handleEscapeKey); } // -- Init -- function initialize() { updateDateTime(); updateToggleButtons(); updateDailyAnalysisUI(); displayAudioResults(null); loadSettingsFromStorage(); setupEventListeners(); const today = new Date().toISOString().split('T')[0]; if (DOMElements.startDateInput) DOMElements.startDateInput.value = today; if (DOMElements.endDateInput) DOMElements.endDateInput.value = today; setInterval(updateDateTime, 30000); addLog('System initialized. Welcome! IKGC', 'info'); startAllStreams(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize); } else { initialize(); }