mirror of
https://github.com/Alvin-Zilverstand/narrow_casting_system.git
synced 2026-03-06 02:57:17 +01:00
✅ 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! 🎿❄️
387 lines
12 KiB
JavaScript
387 lines
12 KiB
JavaScript
// 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(); |