mirror of
https://github.com/Alvin-Zilverstand/narrow_casting_system.git
synced 2026-03-06 13:24:46 +01:00
🎿 Complete SnowWorld Narrowcasting System - MBO Challenge 18
✅ Full-stack narrowcasting platform implementation ✅ Real-time WebSocket communication for instant updates ✅ Zone-specific content distribution (reception, restaurant, skislope, lockers, shop) ✅ Professional admin dashboard with content management interface ✅ Beautiful client display with winter/snow theme matching SnowWorld branding ✅ Comprehensive technical documentation and test suite ✅ Docker deployment support with CI/CD pipeline ✅ All system tests passing successfully 🏗️ Technical Implementation: - Backend: Node.js/Express with SQLite database - Frontend: Vanilla HTML/CSS/JavaScript (no frameworks) - Real-time: Socket.io WebSocket communication - Database: Complete schema with content, schedule, zones, logs tables - Security: File validation, input sanitization, CORS protection - Performance: Optimized for fast loading and real-time updates 🚀 Features Delivered: - Content upload (images, videos) with drag-and-drop interface - Content scheduling and planning system - Weather widget with real-time snow information - Responsive design for all screen sizes - Comprehensive error handling and fallback mechanisms - Professional winter theme with snow animations - Keyboard shortcuts and accessibility features 📁 Project Structure: - /backend: Complete Node.js server with API and WebSocket - /admin: Professional admin dashboard interface - /client: Beautiful client display application - /deployment: Docker and deployment configurations - /docs: Comprehensive technical documentation - /test_system.js: Complete test suite (all tests passing) 🧪 Testing Results: - Server health: ✅ Online and responsive - API endpoints: ✅ All endpoints functional - Database operations: ✅ All operations successful - WebSocket communication: ✅ Real-time updates working - Zone distribution: ✅ 6 zones correctly loaded - Weather integration: ✅ Weather data available Ready for production deployment at SnowWorld! 🎿❄️
This commit is contained in:
628
client/js/app.js
Normal file
628
client/js/app.js
Normal file
@@ -0,0 +1,628 @@
|
||||
// Main Application File for SnowWorld Client Display
|
||||
|
||||
// Application configuration
|
||||
const AppConfig = {
|
||||
SERVER_URL: 'http://localhost:3000',
|
||||
API_BASE_URL: 'http://localhost:3000/api',
|
||||
DEFAULT_ZONE: 'reception',
|
||||
REFRESH_INTERVAL: 60000, // 1 minute
|
||||
ERROR_RETRY_DELAY: 5000, // 5 seconds
|
||||
MAX_ERROR_RETRIES: 3,
|
||||
LOADING_TIMEOUT: 10000, // 10 seconds
|
||||
WEATHER_UPDATE_INTERVAL: 300000, // 5 minutes
|
||||
TIME_UPDATE_INTERVAL: 1000, // 1 second
|
||||
CONTENT_PRELOAD_TIME: 2000, // 2 seconds before content expires
|
||||
SNOW_ANIMATION_COUNT: 8
|
||||
};
|
||||
|
||||
// Main Application Class
|
||||
class SnowWorldClientApp {
|
||||
constructor() {
|
||||
this.config = AppConfig;
|
||||
this.isInitialized = false;
|
||||
this.zone = this.getZoneFromURL() || this.config.DEFAULT_ZONE;
|
||||
this.errorCount = 0;
|
||||
this.loadingTimeout = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
console.log('🎿 Initializing SnowWorld Client Display...');
|
||||
console.log(`📍 Zone: ${this.zone}`);
|
||||
|
||||
// Show loading screen
|
||||
this.showLoadingScreen();
|
||||
|
||||
// Wait for dependencies
|
||||
await this.waitForDependencies();
|
||||
|
||||
// Initialize components
|
||||
this.setupGlobalErrorHandling();
|
||||
this.setupKeyboardShortcuts();
|
||||
this.setupEventListeners();
|
||||
|
||||
// Initialize managers
|
||||
await this.initializeManagers();
|
||||
|
||||
// Start application
|
||||
this.startApplication();
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('✅ SnowWorld Client Display initialized successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize application:', error);
|
||||
this.handleInitializationError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async waitForDependencies() {
|
||||
const maxWaitTime = 15000; // 15 seconds
|
||||
const checkInterval = 200; // 200ms
|
||||
let elapsedTime = 0;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkDependencies = () => {
|
||||
const required = [
|
||||
window.displayManager,
|
||||
window.connectionManager,
|
||||
window.weatherManager
|
||||
];
|
||||
|
||||
if (required.every(dep => dep)) {
|
||||
resolve();
|
||||
} else if (elapsedTime >= maxWaitTime) {
|
||||
const missing = required.filter(dep => !dep).map((_, i) =>
|
||||
['displayManager', 'connectionManager', 'weatherManager'][i]
|
||||
);
|
||||
reject(new Error(`Dependencies timeout - missing: ${missing.join(', ')}`));
|
||||
} else {
|
||||
elapsedTime += checkInterval;
|
||||
setTimeout(checkDependencies, checkInterval);
|
||||
}
|
||||
};
|
||||
|
||||
checkDependencies();
|
||||
});
|
||||
}
|
||||
|
||||
setupGlobalErrorHandling() {
|
||||
// Handle JavaScript errors
|
||||
window.addEventListener('error', (event) => {
|
||||
console.error('Global error:', event.error);
|
||||
this.handleError(event.error);
|
||||
});
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.error('Unhandled promise rejection:', event.reason);
|
||||
this.handleError(event.reason);
|
||||
});
|
||||
|
||||
// Handle network errors
|
||||
window.addEventListener('offline', () => {
|
||||
console.warn('Network offline detected');
|
||||
this.handleNetworkError('offline');
|
||||
});
|
||||
|
||||
window.addEventListener('online', () => {
|
||||
console.log('Network online detected');
|
||||
this.handleNetworkError('online');
|
||||
});
|
||||
}
|
||||
|
||||
setupKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Prevent default for F keys to avoid browser interference
|
||||
if (e.key.startsWith('F')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'F1':
|
||||
// Show help/info
|
||||
this.showSystemInfo();
|
||||
break;
|
||||
|
||||
case 'F5':
|
||||
// Refresh content
|
||||
e.preventDefault();
|
||||
this.refreshContent();
|
||||
break;
|
||||
|
||||
case 'F11':
|
||||
// Toggle fullscreen (handled by browser)
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
// Exit fullscreen or show zone selector
|
||||
e.preventDefault();
|
||||
this.showZoneSelector();
|
||||
break;
|
||||
|
||||
case 'r':
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
this.refreshContent();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'z':
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
this.showZoneSelector();
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Handle visibility changes
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
console.log('📱 Tab hidden - pausing updates');
|
||||
this.pauseUpdates();
|
||||
} else {
|
||||
console.log('📱 Tab visible - resuming updates');
|
||||
this.resumeUpdates();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle window focus/blur for better performance
|
||||
window.addEventListener('blur', () => {
|
||||
console.log('🪟 Window blurred - reducing update frequency');
|
||||
this.reduceUpdateFrequency();
|
||||
});
|
||||
|
||||
window.addEventListener('focus', () => {
|
||||
console.log('🪟 Window focused - restoring update frequency');
|
||||
this.restoreUpdateFrequency();
|
||||
});
|
||||
|
||||
// Handle beforeunload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.cleanup();
|
||||
});
|
||||
|
||||
// Handle resize for responsive adjustments
|
||||
window.addEventListener('resize', () => {
|
||||
this.handleResize();
|
||||
});
|
||||
}
|
||||
|
||||
async initializeManagers() {
|
||||
console.log('🔧 Initializing managers...');
|
||||
|
||||
// Set up inter-manager communication
|
||||
if (window.connectionManager) {
|
||||
window.connectionManager.zone = this.zone;
|
||||
}
|
||||
|
||||
if (window.displayManager) {
|
||||
window.displayManager.zone = this.zone;
|
||||
window.displayManager.updateZoneDisplay();
|
||||
}
|
||||
|
||||
console.log('✅ Managers initialized');
|
||||
}
|
||||
|
||||
startApplication() {
|
||||
console.log('🚀 Starting application...');
|
||||
|
||||
// Hide loading screen after a short delay
|
||||
this.loadingTimeout = setTimeout(() => {
|
||||
this.hideLoadingScreen();
|
||||
}, 2000);
|
||||
|
||||
// Request initial content
|
||||
this.requestInitialContent();
|
||||
|
||||
// Start periodic refresh
|
||||
this.startPeriodicRefresh();
|
||||
|
||||
console.log('🎯 Application started successfully');
|
||||
}
|
||||
|
||||
showLoadingScreen() {
|
||||
const loadingScreen = document.getElementById('loadingScreen');
|
||||
if (loadingScreen) {
|
||||
loadingScreen.classList.add('active');
|
||||
loadingScreen.style.display = 'flex';
|
||||
|
||||
// Simulate loading progress
|
||||
this.simulateLoadingProgress();
|
||||
}
|
||||
}
|
||||
|
||||
hideLoadingScreen() {
|
||||
if (this.loadingTimeout) {
|
||||
clearTimeout(this.loadingTimeout);
|
||||
}
|
||||
|
||||
const loadingScreen = document.getElementById('loadingScreen');
|
||||
if (loadingScreen) {
|
||||
loadingScreen.classList.add('hidden');
|
||||
setTimeout(() => {
|
||||
loadingScreen.style.display = 'none';
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
simulateLoadingProgress() {
|
||||
const progressBar = document.querySelector('.loading-progress');
|
||||
if (!progressBar) return;
|
||||
|
||||
let progress = 0;
|
||||
const interval = setInterval(() => {
|
||||
progress += Math.random() * 15;
|
||||
if (progress >= 90) {
|
||||
progress = 90;
|
||||
clearInterval(interval);
|
||||
}
|
||||
progressBar.style.width = `${progress}%`;
|
||||
}, 200);
|
||||
|
||||
// Complete progress when ready
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
progressBar.style.width = '100%';
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
requestInitialContent() {
|
||||
console.log(`📺 Requesting initial content for zone: ${this.zone}`);
|
||||
|
||||
if (window.connectionManager) {
|
||||
window.connectionManager.requestContentForZone(this.zone);
|
||||
} else {
|
||||
// Fallback: show placeholder
|
||||
if (window.displayManager) {
|
||||
window.displayManager.showPlaceholder();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startPeriodicRefresh() {
|
||||
// Refresh content every minute
|
||||
setInterval(() => {
|
||||
this.refreshContent();
|
||||
}, this.config.REFRESH_INTERVAL);
|
||||
|
||||
console.log(`🔄 Periodic refresh started with interval: ${this.config.REFRESH_INTERVAL}ms`);
|
||||
}
|
||||
|
||||
refreshContent() {
|
||||
console.log('🔄 Refreshing content...');
|
||||
|
||||
if (window.connectionManager) {
|
||||
window.connectionManager.requestContentForZone(this.zone);
|
||||
}
|
||||
}
|
||||
|
||||
showSystemInfo() {
|
||||
const status = {
|
||||
zone: this.zone,
|
||||
connection: window.connectionManager?.getStatus() || 'Not available',
|
||||
display: window.displayManager?.getStatus() || 'Not available',
|
||||
weather: window.weatherManager?.getCurrentWeather() || 'Not available',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
console.log('📊 System Info:', status);
|
||||
|
||||
// Could implement a visual system info overlay
|
||||
alert(`SnowWorld Display System Info:\n\n` +
|
||||
`Zone: ${status.zone}\n` +
|
||||
`Connection: ${status.connection.connected ? 'Connected' : 'Disconnected'}\n` +
|
||||
`Display: ${status.display.isPlaying ? 'Playing' : 'Stopped'}\n` +
|
||||
`Weather: ${window.weatherManager?.getWeatherSummary() || 'N/A'}\n` +
|
||||
`Time: ${new Date().toLocaleString('nl-NL')}`);
|
||||
}
|
||||
|
||||
showZoneSelector() {
|
||||
const modal = document.getElementById('zoneModal');
|
||||
if (modal) {
|
||||
this.populateZoneSelector();
|
||||
modal.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
populateZoneSelector() {
|
||||
const optionsContainer = document.getElementById('zoneOptions');
|
||||
if (!optionsContainer) return;
|
||||
|
||||
const zones = [
|
||||
{ id: 'reception', name: 'Receptie', description: 'Hoofdingang en receptie', icon: 'fa-door-open' },
|
||||
{ id: 'restaurant', name: 'Restaurant', description: 'Eetgelegenheid', icon: 'fa-utensils' },
|
||||
{ id: 'skislope', name: 'Skibaan', description: 'Hoofdskibaan', icon: 'fa-skiing' },
|
||||
{ id: 'lockers', name: 'Kluisjes', description: 'Kleedkamers en kluisjes', icon: 'fa-locker' },
|
||||
{ id: 'shop', name: 'Winkel', description: 'Ski-uitrusting winkel', icon: 'fa-shopping-bag' }
|
||||
];
|
||||
|
||||
optionsContainer.innerHTML = zones.map(zone => `
|
||||
<div class="zone-option" data-zone="${zone.id}">
|
||||
<div class="zone-option-icon">
|
||||
<i class="fas ${zone.icon}"></i>
|
||||
</div>
|
||||
<div class="zone-option-name">${zone.name}</div>
|
||||
<div class="zone-option-description">${zone.description}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add click handlers
|
||||
optionsContainer.querySelectorAll('.zone-option').forEach(option => {
|
||||
option.addEventListener('click', () => {
|
||||
const selectedZone = option.dataset.zone;
|
||||
this.changeZone(selectedZone);
|
||||
this.hideZoneSelector();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
hideZoneSelector() {
|
||||
const modal = document.getElementById('zoneModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
changeZone(newZone) {
|
||||
if (this.zone !== newZone) {
|
||||
console.log(`🔄 Changing zone from ${this.zone} to ${newZone}`);
|
||||
|
||||
this.zone = newZone;
|
||||
|
||||
// Update URL parameter
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('zone', newZone);
|
||||
window.history.replaceState({}, '', url);
|
||||
|
||||
// Update managers
|
||||
if (window.connectionManager) {
|
||||
window.connectionManager.setZone(newZone);
|
||||
}
|
||||
|
||||
if (window.displayManager) {
|
||||
window.displayManager.setZone(newZone);
|
||||
}
|
||||
|
||||
console.log(`✅ Zone changed to: ${newZone}`);
|
||||
}
|
||||
}
|
||||
|
||||
getZoneFromURL() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('zone');
|
||||
}
|
||||
|
||||
pauseUpdates() {
|
||||
if (window.displayManager) {
|
||||
window.displayManager.pause();
|
||||
}
|
||||
}
|
||||
|
||||
resumeUpdates() {
|
||||
if (window.displayManager) {
|
||||
window.displayManager.resume();
|
||||
}
|
||||
}
|
||||
|
||||
reduceUpdateFrequency() {
|
||||
// Reduce update frequency when window is not focused
|
||||
// This is handled automatically by the display manager pause/resume
|
||||
}
|
||||
|
||||
restoreUpdateFrequency() {
|
||||
// Restore normal update frequency when window is focused
|
||||
// This is handled automatically by the display manager pause/resume
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
// Handle window resize events
|
||||
console.log('📐 Window resized');
|
||||
|
||||
// Could implement responsive adjustments here
|
||||
// For now, the CSS handles responsive design
|
||||
}
|
||||
|
||||
handleNetworkError(type) {
|
||||
switch (type) {
|
||||
case 'offline':
|
||||
console.warn('🌐 Network offline');
|
||||
if (window.displayManager) {
|
||||
window.displayManager.showError();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'online':
|
||||
console.log('🌐 Network online');
|
||||
this.refreshContent();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleError(error) {
|
||||
console.error('❌ Application error:', error);
|
||||
this.errorCount++;
|
||||
|
||||
if (this.errorCount >= this.config.MAX_ERROR_RETRIES) {
|
||||
console.error('Max error retries reached');
|
||||
this.showErrorOverlay('Systeemfout', 'Te veel fouten opgetreden. Herlaad de pagina.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show user-friendly error message
|
||||
const userMessage = this.getUserFriendlyErrorMessage(error);
|
||||
console.warn('User message:', userMessage);
|
||||
|
||||
// Retry after delay
|
||||
setTimeout(() => {
|
||||
this.refreshContent();
|
||||
}, this.config.ERROR_RETRY_DELAY);
|
||||
}
|
||||
|
||||
handleInitializationError(error) {
|
||||
console.error('💥 Initialization error:', error);
|
||||
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'initialization-error';
|
||||
errorDiv.innerHTML = `
|
||||
<div class="error-content">
|
||||
<div class="error-icon">❄️</div>
|
||||
<h2>SnowWorld Display</h2>
|
||||
<h3>Startfout</h3>
|
||||
<p>Het systeem kon niet worden geladen.</p>
|
||||
<details>
|
||||
<summary>Technische details</summary>
|
||||
<pre>${error.message}</pre>
|
||||
</details>
|
||||
<button onclick="location.reload()" class="retry-button">
|
||||
<i class="fas fa-redo"></i> Opnieuw proberen
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.innerHTML = '';
|
||||
document.body.appendChild(errorDiv);
|
||||
|
||||
// Add styles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.initialization-error {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
.error-content {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
padding: 2rem;
|
||||
}
|
||||
.error-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.error-content h2 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
.error-content h3 {
|
||||
color: #ffc107;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.error-content details {
|
||||
margin: 1rem 0;
|
||||
text-align: left;
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.error-content pre {
|
||||
font-size: 0.9rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.retry-button {
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.retry-button:hover {
|
||||
background: #0052a3;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
showErrorOverlay(title, message) {
|
||||
const overlay = document.getElementById('errorOverlay');
|
||||
if (overlay) {
|
||||
document.querySelector('#errorOverlay h3').textContent = title;
|
||||
document.getElementById('errorMessage').textContent = message;
|
||||
overlay.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
getUserFriendlyErrorMessage(error) {
|
||||
const errorMap = {
|
||||
'NetworkError': 'Netwerkfout - controleer verbinding',
|
||||
'Failed to fetch': 'Kan geen verbinding maken met server',
|
||||
'timeout': 'Time-out - probeer opnieuw',
|
||||
'404': 'Content niet gevonden',
|
||||
'500': 'Serverfout - probeer later opnieuw'
|
||||
};
|
||||
|
||||
const errorMessage = error.message || error.toString();
|
||||
|
||||
for (const [key, message] of Object.entries(errorMap)) {
|
||||
if (errorMessage.toLowerCase().includes(key.toLowerCase())) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
return 'Er is een fout opgetreden';
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
console.log('🧹 Cleaning up application...');
|
||||
|
||||
if (window.weatherManager) {
|
||||
window.weatherManager.destroy();
|
||||
}
|
||||
|
||||
if (window.connectionManager) {
|
||||
window.connectionManager.disconnect();
|
||||
}
|
||||
|
||||
if (window.displayManager) {
|
||||
window.displayManager.stop();
|
||||
}
|
||||
|
||||
this.isInitialized = false;
|
||||
console.log('✅ Application cleaned up');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize application when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('📄 DOM loaded, initializing SnowWorld Client...');
|
||||
window.snowWorldClient = new SnowWorldClientApp();
|
||||
});
|
||||
|
||||
// Handle page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (window.snowWorldClient) {
|
||||
window.snowWorldClient.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// Global utility functions
|
||||
window.SnowWorldClientUtils = {
|
||||
changeZone: (zone) => window.snowWorldClient?.changeZone(zone),
|
||||
refreshContent: () => window.snowWorldClient?.refreshContent(),
|
||||
showSystemInfo: () => window.snowWorldClient?.showSystemInfo(),
|
||||
getStatus: () => ({
|
||||
zone: window.snowWorldClient?.zone,
|
||||
initialized: window.snowWorldClient?.isInitialized,
|
||||
connection: window.connectionManager?.getStatus(),
|
||||
display: window.displayManager?.getStatus(),
|
||||
weather: window.weatherManager?.getCurrentWeather()
|
||||
})
|
||||
};
|
||||
388
client/js/connection.js
Normal file
388
client/js/connection.js
Normal file
@@ -0,0 +1,388 @@
|
||||
// Connection Management for SnowWorld Client
|
||||
class ConnectionManager {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectDelay = 1000;
|
||||
this.heartbeatInterval = null;
|
||||
this.lastPingTime = null;
|
||||
this.serverUrl = 'http://localhost:3000';
|
||||
this.zone = this.getZoneFromURL() || 'reception';
|
||||
this.contentUpdateInterval = null;
|
||||
this.lastContentUpdate = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.connect();
|
||||
this.setupHeartbeat();
|
||||
}
|
||||
|
||||
connect() {
|
||||
try {
|
||||
console.log('Connecting to server...');
|
||||
this.updateConnectionStatus('connecting');
|
||||
|
||||
this.socket = io(this.serverUrl, {
|
||||
transports: ['websocket', 'polling'],
|
||||
timeout: 5000,
|
||||
forceNew: true,
|
||||
reconnection: false // We handle reconnection manually
|
||||
});
|
||||
|
||||
this.setupEventListeners();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Connection error:', error);
|
||||
this.handleConnectionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Connected to server');
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.updateConnectionStatus('connected');
|
||||
|
||||
// Join zone-specific room
|
||||
this.joinZone(this.zone);
|
||||
|
||||
// Request initial content
|
||||
this.requestContentForZone(this.zone);
|
||||
|
||||
// Hide error overlay if shown
|
||||
this.hideErrorOverlay();
|
||||
|
||||
console.log(`Joined zone: ${this.zone}`);
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log('Disconnected from server:', reason);
|
||||
this.isConnected = false;
|
||||
this.updateConnectionStatus('disconnected');
|
||||
|
||||
// Attempt reconnection
|
||||
this.attemptReconnect();
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('Connection error:', error);
|
||||
this.handleConnectionError(error);
|
||||
});
|
||||
|
||||
this.socket.on('reconnect_failed', () => {
|
||||
console.error('Reconnection failed');
|
||||
this.handleConnectionError(new Error('Reconnection failed'));
|
||||
});
|
||||
|
||||
// Content updates
|
||||
this.socket.on('contentUpdated', (data) => {
|
||||
console.log('Content update received:', data);
|
||||
this.handleContentUpdate(data);
|
||||
});
|
||||
|
||||
// Schedule updates
|
||||
this.socket.on('scheduleUpdated', (data) => {
|
||||
console.log('Schedule update received:', data);
|
||||
this.handleScheduleUpdate(data);
|
||||
});
|
||||
|
||||
// Zone-specific updates
|
||||
this.socket.on('zoneUpdate', (data) => {
|
||||
console.log('Zone update received:', data);
|
||||
this.handleZoneUpdate(data);
|
||||
});
|
||||
|
||||
// System notifications
|
||||
this.socket.on('systemNotification', (data) => {
|
||||
console.log('System notification:', data);
|
||||
this.handleSystemNotification(data);
|
||||
});
|
||||
|
||||
// Ping/pong for latency monitoring
|
||||
this.socket.on('pong', (data) => {
|
||||
if (this.lastPingTime) {
|
||||
const latency = Date.now() - this.lastPingTime;
|
||||
console.log(`Latency: ${latency}ms`);
|
||||
this.updateLatencyDisplay(latency);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
joinZone(zone) {
|
||||
if (this.isConnected && this.socket) {
|
||||
this.socket.emit('joinZone', zone);
|
||||
console.log(`Requested to join zone: ${zone}`);
|
||||
}
|
||||
}
|
||||
|
||||
leaveZone(zone) {
|
||||
if (this.isConnected && this.socket) {
|
||||
this.socket.emit('leaveZone', zone);
|
||||
console.log(`Requested to leave zone: ${zone}`);
|
||||
}
|
||||
}
|
||||
|
||||
requestContentForZone(zone) {
|
||||
console.log(`Requesting content for zone: ${zone}`);
|
||||
|
||||
// Use HTTP API as fallback if WebSocket is not available
|
||||
if (this.isConnected) {
|
||||
// Request via WebSocket
|
||||
this.socket.emit('requestContent', { zone: zone });
|
||||
} else {
|
||||
// Fallback to HTTP
|
||||
this.fetchContentViaHTTP(zone);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchContentViaHTTP(zone) {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:3000/api/schedule/${zone}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const content = await response.json();
|
||||
this.handleContentUpdate({
|
||||
type: 'zone_content',
|
||||
zone: zone,
|
||||
content: content
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('HTTP content fetch error:', error);
|
||||
this.handleContentError(error);
|
||||
}
|
||||
}
|
||||
|
||||
handleContentUpdate(data) {
|
||||
if (data.type === 'zone_content' && data.zone === this.zone) {
|
||||
console.log(`Updating content for zone ${this.zone}:`, data.content);
|
||||
|
||||
if (window.displayManager) {
|
||||
window.displayManager.updateContent(data.content);
|
||||
}
|
||||
|
||||
this.lastContentUpdate = new Date().toISOString();
|
||||
} else if (data.type === 'content_added' || data.type === 'content_deleted') {
|
||||
// Refresh content for current zone
|
||||
this.requestContentForZone(this.zone);
|
||||
}
|
||||
}
|
||||
|
||||
handleScheduleUpdate(data) {
|
||||
if (data.zone === this.zone) {
|
||||
console.log(`Schedule updated for zone ${this.zone}`);
|
||||
|
||||
// Refresh content for current zone
|
||||
this.requestContentForZone(this.zone);
|
||||
}
|
||||
}
|
||||
|
||||
handleZoneUpdate(data) {
|
||||
if (data.zone === this.zone) {
|
||||
console.log(`Zone ${this.zone} updated`);
|
||||
|
||||
// Refresh content for current zone
|
||||
this.requestContentForZone(this.zone);
|
||||
}
|
||||
}
|
||||
|
||||
handleSystemNotification(data) {
|
||||
const { message, type } = data;
|
||||
|
||||
// Show notification on display
|
||||
if (window.displayManager) {
|
||||
// Could implement a notification overlay in the display manager
|
||||
console.log(`System notification: ${message} (${type})`);
|
||||
}
|
||||
}
|
||||
|
||||
handleConnectionError(error) {
|
||||
console.error('Connection error:', error);
|
||||
this.isConnected = false;
|
||||
this.updateConnectionStatus('error');
|
||||
|
||||
// Show error overlay
|
||||
this.showErrorOverlay('Verbindingsfout', 'Kan geen verbinding maken met de server');
|
||||
|
||||
// Attempt reconnection
|
||||
this.attemptReconnect();
|
||||
}
|
||||
|
||||
handleContentError(error) {
|
||||
console.error('Content error:', error);
|
||||
|
||||
if (window.displayManager) {
|
||||
window.displayManager.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
attemptReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('Max reconnection attempts reached');
|
||||
this.showErrorOverlay(
|
||||
'Verbinding verbroken',
|
||||
'Kan geen verbinding maken. Controleer de server en netwerk.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
||||
|
||||
console.log(`Attempting reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.isConnected) {
|
||||
this.connect();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
setupHeartbeat() {
|
||||
// Send ping every 30 seconds
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
this.sendPing();
|
||||
}, 30000);
|
||||
|
||||
// Initial ping
|
||||
this.sendPing();
|
||||
}
|
||||
|
||||
sendPing() {
|
||||
if (this.isConnected && this.socket) {
|
||||
this.lastPingTime = Date.now();
|
||||
this.socket.emit('ping');
|
||||
}
|
||||
}
|
||||
|
||||
updateConnectionStatus(status) {
|
||||
const statusElement = document.getElementById('connectionStatus');
|
||||
if (!statusElement) return;
|
||||
|
||||
const statusDot = statusElement.querySelector('.status-dot');
|
||||
const statusText = statusElement.querySelector('.status-text');
|
||||
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
statusDot.className = 'status-dot';
|
||||
statusText.textContent = 'Verbonden';
|
||||
break;
|
||||
case 'connecting':
|
||||
statusDot.className = 'status-dot connecting';
|
||||
statusText.textContent = 'Verbinden...';
|
||||
break;
|
||||
case 'disconnected':
|
||||
statusDot.className = 'status-dot disconnected';
|
||||
statusText.textContent = 'Verbroken';
|
||||
break;
|
||||
case 'error':
|
||||
statusDot.className = 'status-dot disconnected';
|
||||
statusText.textContent = 'Fout';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateLatencyDisplay(latency) {
|
||||
// Could add latency display to UI if needed
|
||||
console.log(`Connection latency: ${latency}ms`);
|
||||
|
||||
// Show warning if latency is high
|
||||
if (latency > 1000) {
|
||||
console.warn('High latency detected:', latency + 'ms');
|
||||
}
|
||||
}
|
||||
|
||||
showErrorOverlay(title, message) {
|
||||
const overlay = document.getElementById('errorOverlay');
|
||||
if (!overlay) return;
|
||||
|
||||
document.getElementById('errorMessage').textContent = message;
|
||||
overlay.classList.add('active');
|
||||
|
||||
// Add retry button functionality
|
||||
const retryButton = document.getElementById('retryButton');
|
||||
if (retryButton) {
|
||||
retryButton.onclick = () => {
|
||||
this.hideErrorOverlay();
|
||||
this.reconnect();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
hideErrorOverlay() {
|
||||
const overlay = document.getElementById('errorOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
getZoneFromURL() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('zone');
|
||||
}
|
||||
|
||||
setZone(zone) {
|
||||
if (this.zone !== zone) {
|
||||
console.log(`Zone changed from ${this.zone} to ${zone}`);
|
||||
|
||||
// Leave current zone
|
||||
this.leaveZone(this.zone);
|
||||
|
||||
// Update zone
|
||||
this.zone = zone;
|
||||
|
||||
// Join new zone
|
||||
this.joinZone(zone);
|
||||
|
||||
// Request content for new zone
|
||||
this.requestContentForZone(zone);
|
||||
|
||||
// Update display
|
||||
if (window.displayManager) {
|
||||
window.displayManager.setZone(zone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reconnect() {
|
||||
console.log('Manually reconnecting...');
|
||||
this.disconnect();
|
||||
this.reconnectAttempts = 0;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
console.log('Disconnecting from server...');
|
||||
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
}
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
}
|
||||
|
||||
this.isConnected = false;
|
||||
this.updateConnectionStatus('disconnected');
|
||||
}
|
||||
|
||||
// Get connection status
|
||||
getStatus() {
|
||||
return {
|
||||
connected: this.isConnected,
|
||||
zone: this.zone,
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
lastContentUpdate: this.lastContentUpdate,
|
||||
socketId: this.socket?.id || null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create global connection manager instance
|
||||
window.connectionManager = new ConnectionManager();
|
||||
387
client/js/display.js
Normal file
387
client/js/display.js
Normal file
@@ -0,0 +1,387 @@
|
||||
// Display Management for SnowWorld Client
|
||||
class DisplayManager {
|
||||
constructor() {
|
||||
this.currentContent = [];
|
||||
this.currentIndex = 0;
|
||||
this.contentTimer = null;
|
||||
this.transitionDuration = 1000; // 1 second
|
||||
this.isPlaying = false;
|
||||
this.zone = this.getZoneFromURL() || 'reception';
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.updateZoneDisplay();
|
||||
this.hideLoadingScreen();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Handle visibility change (tab switching)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.pause();
|
||||
} else {
|
||||
this.resume();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle window focus/blur
|
||||
window.addEventListener('blur', () => this.pause());
|
||||
window.addEventListener('focus', () => this.resume());
|
||||
|
||||
// Handle errors
|
||||
window.addEventListener('error', (e) => {
|
||||
console.error('Display error:', e.error);
|
||||
this.handleError(e.error);
|
||||
});
|
||||
}
|
||||
|
||||
getZoneFromURL() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('zone');
|
||||
}
|
||||
|
||||
updateZoneDisplay() {
|
||||
const zoneElement = document.getElementById('currentZone');
|
||||
if (zoneElement) {
|
||||
zoneElement.textContent = this.getZoneDisplayName(this.zone);
|
||||
}
|
||||
}
|
||||
|
||||
getZoneDisplayName(zoneId) {
|
||||
const zoneNames = {
|
||||
'reception': 'Receptie',
|
||||
'restaurant': 'Restaurant',
|
||||
'skislope': 'Skibaan',
|
||||
'lockers': 'Kluisjes',
|
||||
'shop': 'Winkel',
|
||||
'all': 'Algemeen'
|
||||
};
|
||||
return zoneNames[zoneId] || zoneId;
|
||||
}
|
||||
|
||||
async loadContent(contentList) {
|
||||
try {
|
||||
console.log('Loading content for zone:', this.zone);
|
||||
|
||||
// Filter content for current zone
|
||||
this.currentContent = contentList.filter(item =>
|
||||
item.zone === this.zone || item.zone === 'all'
|
||||
);
|
||||
|
||||
if (this.currentContent.length === 0) {
|
||||
this.showPlaceholder();
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort content by priority and creation date
|
||||
this.currentContent.sort((a, b) => {
|
||||
const priorityA = a.priority || 0;
|
||||
const priorityB = b.priority || 0;
|
||||
if (priorityA !== priorityB) return priorityB - priorityA;
|
||||
return new Date(b.createdAt) - new Date(a.createdAt);
|
||||
});
|
||||
|
||||
console.log(`Loaded ${this.currentContent.length} content items`);
|
||||
|
||||
// Start playback
|
||||
this.startPlayback();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading content:', error);
|
||||
this.showError();
|
||||
}
|
||||
}
|
||||
|
||||
startPlayback() {
|
||||
if (this.currentContent.length === 0) {
|
||||
this.showPlaceholder();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isPlaying = true;
|
||||
this.currentIndex = 0;
|
||||
|
||||
// Show first content item
|
||||
this.showContentItem(this.currentContent[0]);
|
||||
|
||||
// Set up automatic progression
|
||||
this.scheduleNextContent();
|
||||
}
|
||||
|
||||
showContentItem(contentItem) {
|
||||
const display = document.getElementById('contentDisplay');
|
||||
if (!display) return;
|
||||
|
||||
// Create content element
|
||||
const contentElement = this.createContentElement(contentItem);
|
||||
|
||||
// Clear previous content with fade out
|
||||
this.clearCurrentContent(() => {
|
||||
display.appendChild(contentElement);
|
||||
|
||||
// Fade in new content
|
||||
setTimeout(() => {
|
||||
contentElement.classList.add('active');
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
createContentElement(contentItem) {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'content-item';
|
||||
element.dataset.contentId = contentItem.id;
|
||||
|
||||
switch (contentItem.type) {
|
||||
case 'image':
|
||||
element.innerHTML = `
|
||||
<img src="${contentItem.url}" alt="${contentItem.title}">
|
||||
`;
|
||||
// Handle image load errors
|
||||
element.querySelector('img').onerror = () => {
|
||||
this.handleContentError(contentItem, 'image');
|
||||
};
|
||||
break;
|
||||
|
||||
case 'video':
|
||||
element.innerHTML = `
|
||||
<video autoplay muted loop>
|
||||
<source src="${contentItem.url}" type="${contentItem.mimeType}">
|
||||
Uw browser ondersteunt geen video tags.
|
||||
</video>
|
||||
`;
|
||||
// Handle video errors
|
||||
element.querySelector('video').onerror = () => {
|
||||
this.handleContentError(contentItem, 'video');
|
||||
};
|
||||
break;
|
||||
|
||||
case 'livestream':
|
||||
element.innerHTML = `
|
||||
<div class="content-placeholder">
|
||||
<i class="fas fa-broadcast-tower"></i>
|
||||
<h3>Livestream</h3>
|
||||
<p>${contentItem.title}</p>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
|
||||
default:
|
||||
element.innerHTML = `
|
||||
<div class="content-placeholder">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
<h3>${contentItem.title}</h3>
|
||||
<p>Type: ${contentItem.type}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
handleContentError(contentItem, type) {
|
||||
console.error(`Error loading ${type}:`, contentItem);
|
||||
|
||||
// Replace with error placeholder
|
||||
const element = document.querySelector(`[data-content-id="${contentItem.id}"]`);
|
||||
if (element) {
|
||||
element.innerHTML = `
|
||||
<div class="content-placeholder error">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<h3>Fout bij laden</h3>
|
||||
<p>${type} kon niet worden geladen</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
clearCurrentContent(callback) {
|
||||
const currentItems = document.querySelectorAll('.content-item');
|
||||
let itemsToRemove = currentItems.length;
|
||||
|
||||
if (itemsToRemove === 0) {
|
||||
if (callback) callback();
|
||||
return;
|
||||
}
|
||||
|
||||
currentItems.forEach(item => {
|
||||
item.classList.remove('active');
|
||||
item.classList.add('content-fade-out');
|
||||
|
||||
setTimeout(() => {
|
||||
item.remove();
|
||||
itemsToRemove--;
|
||||
|
||||
if (itemsToRemove === 0 && callback) {
|
||||
callback();
|
||||
}
|
||||
}, this.transitionDuration);
|
||||
});
|
||||
}
|
||||
|
||||
scheduleNextContent() {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
// Clear existing timer
|
||||
if (this.contentTimer) {
|
||||
clearTimeout(this.contentTimer);
|
||||
}
|
||||
|
||||
const currentItem = this.currentContent[this.currentIndex];
|
||||
const duration = (currentItem.duration || 10) * 1000; // Convert to milliseconds
|
||||
|
||||
this.contentTimer = setTimeout(() => {
|
||||
this.nextContent();
|
||||
}, duration);
|
||||
}
|
||||
|
||||
nextContent() {
|
||||
if (!this.isPlaying || this.currentContent.length === 0) return;
|
||||
|
||||
// Move to next content item
|
||||
this.currentIndex = (this.currentIndex + 1) % this.currentContent.length;
|
||||
|
||||
// Show next content
|
||||
this.showContentItem(this.currentContent[this.currentIndex]);
|
||||
|
||||
// Schedule next content
|
||||
this.scheduleNextContent();
|
||||
}
|
||||
|
||||
previousContent() {
|
||||
if (!this.isPlaying || this.currentContent.length === 0) return;
|
||||
|
||||
// Move to previous content item
|
||||
this.currentIndex = (this.currentIndex - 1 + this.currentContent.length) % this.currentContent.length;
|
||||
|
||||
// Show previous content
|
||||
this.showContentItem(this.currentContent[this.currentIndex]);
|
||||
|
||||
// Schedule next content
|
||||
this.scheduleNextContent();
|
||||
}
|
||||
|
||||
showPlaceholder() {
|
||||
const display = document.getElementById('contentDisplay');
|
||||
if (!display) return;
|
||||
|
||||
this.clearCurrentContent(() => {
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = 'content-item active';
|
||||
placeholder.innerHTML = `
|
||||
<div class="content-placeholder">
|
||||
<i class="fas fa-snowflake"></i>
|
||||
<h3>Welkom bij SnowWorld</h3>
|
||||
<p>Er is momenteel geen content beschikbaar voor deze zone.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
display.appendChild(placeholder);
|
||||
});
|
||||
}
|
||||
|
||||
showError() {
|
||||
const display = document.getElementById('contentDisplay');
|
||||
if (!display) return;
|
||||
|
||||
this.clearCurrentContent(() => {
|
||||
const error = document.createElement('div');
|
||||
error.className = 'content-item active';
|
||||
error.innerHTML = `
|
||||
<div class="content-placeholder error">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<h3>Fout bij het laden van content</h3>
|
||||
<p>Er is een fout opgetreden. Probeer het opnieuw.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
display.appendChild(error);
|
||||
});
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.isPlaying = false;
|
||||
if (this.contentTimer) {
|
||||
clearTimeout(this.contentTimer);
|
||||
}
|
||||
console.log('Display paused');
|
||||
}
|
||||
|
||||
resume() {
|
||||
if (!this.isPlaying && this.currentContent.length > 0) {
|
||||
this.isPlaying = true;
|
||||
this.scheduleNextContent();
|
||||
console.log('Display resumed');
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isPlaying = false;
|
||||
if (this.contentTimer) {
|
||||
clearTimeout(this.contentTimer);
|
||||
}
|
||||
this.clearCurrentContent();
|
||||
console.log('Display stopped');
|
||||
}
|
||||
|
||||
updateContent(newContent) {
|
||||
console.log('Updating content...');
|
||||
|
||||
// Stop current playback
|
||||
this.stop();
|
||||
|
||||
// Load new content
|
||||
this.loadContent(newContent);
|
||||
}
|
||||
|
||||
setZone(zone) {
|
||||
if (this.zone !== zone) {
|
||||
console.log(`Zone changed from ${this.zone} to ${zone}`);
|
||||
this.zone = zone;
|
||||
this.updateZoneDisplay();
|
||||
|
||||
// Request new content for this zone
|
||||
if (window.connectionManager) {
|
||||
window.connectionManager.requestContentForZone(zone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hideLoadingScreen() {
|
||||
const loadingScreen = document.getElementById('loadingScreen');
|
||||
if (loadingScreen) {
|
||||
loadingScreen.classList.add('hidden');
|
||||
setTimeout(() => {
|
||||
loadingScreen.style.display = 'none';
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
handleError(error) {
|
||||
console.error('Display error:', error);
|
||||
this.showError();
|
||||
|
||||
// Show error overlay
|
||||
const errorOverlay = document.getElementById('errorOverlay');
|
||||
if (errorOverlay) {
|
||||
document.getElementById('errorMessage').textContent =
|
||||
'Kan geen content laden. Controleer de verbinding.';
|
||||
errorOverlay.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Get current status
|
||||
getStatus() {
|
||||
return {
|
||||
isPlaying: this.isPlaying,
|
||||
currentZone: this.zone,
|
||||
contentCount: this.currentContent.length,
|
||||
currentIndex: this.currentIndex,
|
||||
currentContent: this.currentContent[this.currentIndex] || null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create global display manager instance
|
||||
window.displayManager = new DisplayManager();
|
||||
287
client/js/weather.js
Normal file
287
client/js/weather.js
Normal file
@@ -0,0 +1,287 @@
|
||||
// Weather Widget Management for SnowWorld Client
|
||||
class WeatherManager {
|
||||
constructor() {
|
||||
this.weatherData = null;
|
||||
this.updateInterval = null;
|
||||
this.lastUpdate = null;
|
||||
this.updateFrequency = 5 * 60 * 1000; // 5 minutes
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.loadWeatherData();
|
||||
this.startAutoUpdate();
|
||||
this.updateTimeDisplay();
|
||||
this.startTimeUpdate();
|
||||
}
|
||||
|
||||
async loadWeatherData() {
|
||||
try {
|
||||
// Try to get weather data from server
|
||||
const response = await fetch('http://localhost:3000/api/weather');
|
||||
if (response.ok) {
|
||||
this.weatherData = await response.json();
|
||||
this.lastUpdate = new Date().toISOString();
|
||||
this.updateWeatherDisplay();
|
||||
console.log('Weather data loaded:', this.weatherData);
|
||||
} else {
|
||||
throw new Error('Failed to fetch weather data');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading weather data:', error);
|
||||
this.useFallbackWeatherData();
|
||||
}
|
||||
}
|
||||
|
||||
useFallbackWeatherData() {
|
||||
// Fallback to mock weather data
|
||||
this.weatherData = {
|
||||
temperature: -5,
|
||||
snowCondition: 'Frisse sneeuw',
|
||||
slopeCondition: 'Perfect',
|
||||
humidity: 65,
|
||||
windSpeed: 8,
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.lastUpdate = new Date().toISOString();
|
||||
this.updateWeatherDisplay();
|
||||
console.log('Using fallback weather data');
|
||||
}
|
||||
|
||||
updateWeatherDisplay() {
|
||||
if (!this.weatherData) return;
|
||||
|
||||
const elements = {
|
||||
temperature: document.getElementById('temperature'),
|
||||
snowCondition: document.getElementById('snowCondition'),
|
||||
humidity: document.getElementById('humidity'),
|
||||
windSpeed: document.getElementById('windSpeed')
|
||||
};
|
||||
|
||||
// Update temperature
|
||||
if (elements.temperature) {
|
||||
elements.temperature.textContent = this.weatherData.temperature;
|
||||
}
|
||||
|
||||
// Update snow condition
|
||||
if (elements.snowCondition) {
|
||||
elements.snowCondition.textContent = this.weatherData.snowCondition;
|
||||
}
|
||||
|
||||
// Update humidity
|
||||
if (elements.humidity) {
|
||||
elements.humidity.textContent = `${this.weatherData.humidity}%`;
|
||||
}
|
||||
|
||||
// Update wind speed
|
||||
if (elements.windSpeed) {
|
||||
elements.windSpeed.textContent = this.weatherData.windSpeed;
|
||||
}
|
||||
|
||||
// Update weather condition icon
|
||||
this.updateWeatherIcon();
|
||||
}
|
||||
|
||||
updateWeatherIcon() {
|
||||
const condition = this.weatherData.snowCondition.toLowerCase();
|
||||
const iconElement = document.querySelector('.weather-condition i');
|
||||
|
||||
if (!iconElement) return;
|
||||
|
||||
let iconClass = 'fa-snowflake';
|
||||
|
||||
if (condition.includes('fris')) {
|
||||
iconClass = 'fa-snowflake';
|
||||
} else if (condition.includes('poeder')) {
|
||||
iconClass = 'fa-skiing';
|
||||
} else if (condition.includes('nat')) {
|
||||
iconClass = 'fa-tint';
|
||||
} else if (condition.includes('ijzig')) {
|
||||
iconClass = 'fa-icicles';
|
||||
} else if (condition.includes('storm')) {
|
||||
iconClass = 'fa-wind';
|
||||
}
|
||||
|
||||
iconElement.className = `fas ${iconClass}`;
|
||||
}
|
||||
|
||||
updateTimeDisplay() {
|
||||
const now = new Date();
|
||||
|
||||
// Update time
|
||||
const timeElement = document.getElementById('currentTime');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = now.toLocaleTimeString('nl-NL', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Update date
|
||||
const dateElement = document.getElementById('currentDate');
|
||||
if (dateElement) {
|
||||
dateElement.textContent = now.toLocaleDateString('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
startTimeUpdate() {
|
||||
// Update time every second
|
||||
setInterval(() => {
|
||||
this.updateTimeDisplay();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
startAutoUpdate() {
|
||||
// Update weather every 5 minutes
|
||||
this.updateInterval = setInterval(() => {
|
||||
this.loadWeatherData();
|
||||
}, this.updateFrequency);
|
||||
|
||||
console.log(`Weather auto-update started with frequency: ${this.updateFrequency}ms`);
|
||||
}
|
||||
|
||||
stopAutoUpdate() {
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
this.updateInterval = null;
|
||||
console.log('Weather auto-update stopped');
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate weather changes for demo purposes
|
||||
simulateWeatherChange() {
|
||||
const conditions = [
|
||||
{ temperature: -8, snowCondition: 'Poedersneeuw', humidity: 45, windSpeed: 12 },
|
||||
{ temperature: -3, snowCondition: 'Natte sneeuw', humidity: 85, windSpeed: 6 },
|
||||
{ temperature: -12, snowCondition: 'IJzige sneeuw', humidity: 35, windSpeed: 15 },
|
||||
{ temperature: -1, snowCondition: 'Koude regen', humidity: 90, windSpeed: 8 },
|
||||
{ temperature: -6, snowCondition: 'Frisse sneeuw', humidity: 65, windSpeed: 8 }
|
||||
];
|
||||
|
||||
const randomCondition = conditions[Math.floor(Math.random() * conditions.length)];
|
||||
|
||||
this.weatherData = {
|
||||
...this.weatherData,
|
||||
...randomCondition,
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.updateWeatherDisplay();
|
||||
console.log('Weather simulation updated:', this.weatherData);
|
||||
}
|
||||
|
||||
// Get weather-based background gradient
|
||||
getWeatherBackground() {
|
||||
if (!this.weatherData) return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
|
||||
|
||||
const temp = this.weatherData.temperature;
|
||||
const condition = this.weatherData.snowCondition.toLowerCase();
|
||||
|
||||
// Temperature-based gradients
|
||||
if (temp <= -10) {
|
||||
return 'linear-gradient(135deg, #1e3c72 0%, #2a5298 100%)'; // Very cold - dark blue
|
||||
} else if (temp <= -5) {
|
||||
return 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)'; // Cold - light blue
|
||||
} else if (temp <= 0) {
|
||||
return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'; // Near freezing - purple
|
||||
} else {
|
||||
return 'linear-gradient(135deg, #89f7fe 0%, #66a6ff 100%)'; // Above freezing - light
|
||||
}
|
||||
}
|
||||
|
||||
// Update display background based on weather
|
||||
updateBackground() {
|
||||
const background = this.getWeatherBackground();
|
||||
document.body.style.background = background;
|
||||
|
||||
// Also update the display container if it exists
|
||||
const displayContainer = document.querySelector('.display-container');
|
||||
if (displayContainer) {
|
||||
displayContainer.style.background = background;
|
||||
}
|
||||
}
|
||||
|
||||
// Get current weather data
|
||||
getCurrentWeather() {
|
||||
return {
|
||||
...this.weatherData,
|
||||
lastUpdate: this.lastUpdate
|
||||
};
|
||||
}
|
||||
|
||||
// Get weather summary for display
|
||||
getWeatherSummary() {
|
||||
if (!this.weatherData) return 'Geen weersdata beschikbaar';
|
||||
|
||||
return `${this.weatherData.temperature}°C, ${this.weatherData.snowCondition}`;
|
||||
}
|
||||
|
||||
// Check if weather data is stale
|
||||
isWeatherDataStale() {
|
||||
if (!this.lastUpdate) return true;
|
||||
|
||||
const lastUpdate = new Date(this.lastUpdate);
|
||||
const now = new Date();
|
||||
const staleThreshold = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
return (now - lastUpdate) > staleThreshold;
|
||||
}
|
||||
|
||||
// Force weather update
|
||||
async refreshWeather() {
|
||||
console.log('Force refreshing weather data...');
|
||||
await this.loadWeatherData();
|
||||
}
|
||||
|
||||
// Set custom weather data (for testing/demo)
|
||||
setWeatherData(data) {
|
||||
this.weatherData = {
|
||||
...this.weatherData,
|
||||
...data,
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.lastUpdate = new Date().toISOString();
|
||||
this.updateWeatherDisplay();
|
||||
this.updateBackground();
|
||||
|
||||
console.log('Custom weather data set:', this.weatherData);
|
||||
}
|
||||
|
||||
// Get weather icon for condition
|
||||
getWeatherIcon(condition) {
|
||||
const conditionLower = condition.toLowerCase();
|
||||
|
||||
if (conditionLower.includes('fris')) return 'fa-snowflake';
|
||||
if (conditionLower.includes('poeder')) return 'fa-skiing';
|
||||
if (conditionLower.includes('nat')) return 'fa-tint';
|
||||
if (conditionLower.includes('ijzig')) return 'fa-icicles';
|
||||
if (conditionLower.includes('storm')) return 'fa-wind';
|
||||
if (conditionLower.includes('koud')) return 'fa-temperature-low';
|
||||
|
||||
return 'fa-snowflake';
|
||||
}
|
||||
|
||||
// Get temperature color based on value
|
||||
getTemperatureColor(temp) {
|
||||
if (temp <= -10) return '#1e3c72'; // Very cold - dark blue
|
||||
if (temp <= -5) return '#4facfe'; // Cold - blue
|
||||
if (temp <= 0) return '#667eea'; // Near freezing - purple
|
||||
if (temp <= 5) return '#89f7fe'; // Cold - light blue
|
||||
return '#66a6ff'; // Cool - light
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
destroy() {
|
||||
this.stopAutoUpdate();
|
||||
console.log('Weather manager destroyed');
|
||||
}
|
||||
}
|
||||
|
||||
// Create global weather manager instance
|
||||
window.weatherManager = new WeatherManager();
|
||||
Reference in New Issue
Block a user