Complete HTML-based log analyzer for Moonlight Drive-In Theater NFC video player system. Features: - Parse NFC scan events from runtime logs (not config loading) - Extract and resolve NFC mappings from startup configuration - Statistics dashboard with scan counts, unique videos, and timing metrics - Interactive charts: Timeline, Hourly Activity, Video Counts, Time-Between-Scans - Detailed video playback lists with drill-down functionality - Print Report feature for comprehensive activity reports - Purple gradient theme matching drive-in UI - Complete scan and trailer tables for reporting - No dependencies except Chart.js CDN Technical Implementation: - Single-file HTML with embedded CSS and JavaScript - Dual-pass parsing: Config mappings first, then runtime events - Smart event detection distinguishing NFC scans from config loading - Folder video support with multi-file sequential playback - Print-optimized CSS with page breaks and alternating row colors - Error handling and console debugging for troubleshooting
1256 lines
43 KiB
HTML
1256 lines
43 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Video Player Log Analyzer</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/date-fns@2.29.3/index.min.js"></script>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
min-height: 100vh;
|
||
padding: 20px;
|
||
}
|
||
|
||
.container {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.header {
|
||
background: white;
|
||
padding: 30px;
|
||
border-radius: 15px;
|
||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||
margin-bottom: 20px;
|
||
text-align: center;
|
||
}
|
||
|
||
h1 {
|
||
color: #667eea;
|
||
margin-bottom: 10px;
|
||
font-size: 2.5em;
|
||
}
|
||
|
||
.upload-section {
|
||
background: white;
|
||
padding: 30px;
|
||
border-radius: 15px;
|
||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||
margin-bottom: 20px;
|
||
text-align: center;
|
||
}
|
||
|
||
.file-input-wrapper {
|
||
position: relative;
|
||
display: inline-block;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.file-input-wrapper input[type=file] {
|
||
position: absolute;
|
||
opacity: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.file-input-label {
|
||
display: inline-block;
|
||
padding: 15px 40px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border-radius: 50px;
|
||
cursor: pointer;
|
||
font-size: 1.1em;
|
||
font-weight: 600;
|
||
transition: transform 0.2s, box-shadow 0.2s;
|
||
}
|
||
|
||
.file-input-label:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
||
}
|
||
|
||
.btn-primary {
|
||
display: inline-block;
|
||
padding: 15px 40px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 50px;
|
||
cursor: pointer;
|
||
font-size: 1.1em;
|
||
font-weight: 600;
|
||
transition: transform 0.2s, box-shadow 0.2s;
|
||
margin-left: 15px;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
||
}
|
||
|
||
.btn-primary:active {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||
gap: 20px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.stat-card {
|
||
background: white;
|
||
padding: 25px;
|
||
border-radius: 15px;
|
||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||
text-align: center;
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.stat-card:hover {
|
||
transform: translateY(-5px);
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 3em;
|
||
font-weight: bold;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.stat-label {
|
||
color: #666;
|
||
font-size: 1.1em;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.chart-container {
|
||
background: white;
|
||
padding: 30px;
|
||
border-radius: 15px;
|
||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||
margin-bottom: 20px;
|
||
position: relative;
|
||
height: 500px;
|
||
}
|
||
|
||
.video-list {
|
||
background: white;
|
||
padding: 30px;
|
||
border-radius: 15px;
|
||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.video-list h2 {
|
||
color: #667eea;
|
||
margin-bottom: 20px;
|
||
font-size: 1.8em;
|
||
}
|
||
|
||
.video-item {
|
||
padding: 15px;
|
||
margin-bottom: 10px;
|
||
background: #f8f9fa;
|
||
border-radius: 10px;
|
||
border-left: 5px solid #667eea;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.video-item:hover {
|
||
background: #e9ecef;
|
||
transform: translateX(5px);
|
||
}
|
||
|
||
.video-name {
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.video-stats {
|
||
color: #666;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.detail-modal {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0,0,0,0.7);
|
||
z-index: 1000;
|
||
padding: 20px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.detail-content {
|
||
background: white;
|
||
max-width: 900px;
|
||
margin: 50px auto;
|
||
padding: 40px;
|
||
border-radius: 15px;
|
||
position: relative;
|
||
}
|
||
|
||
.close-btn {
|
||
position: absolute;
|
||
top: 20px;
|
||
right: 20px;
|
||
font-size: 2em;
|
||
cursor: pointer;
|
||
color: #666;
|
||
transition: color 0.2s;
|
||
}
|
||
|
||
.close-btn:hover {
|
||
color: #333;
|
||
}
|
||
|
||
.detail-header {
|
||
color: #667eea;
|
||
margin-bottom: 20px;
|
||
font-size: 2em;
|
||
}
|
||
|
||
.playback-timeline {
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.timeline-item {
|
||
padding: 15px;
|
||
margin-bottom: 10px;
|
||
background: #f8f9fa;
|
||
border-radius: 10px;
|
||
border-left: 3px solid #667eea;
|
||
}
|
||
|
||
.timeline-time {
|
||
font-weight: 600;
|
||
color: #667eea;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.timeline-info {
|
||
color: #666;
|
||
font-size: 0.95em;
|
||
}
|
||
|
||
.filters {
|
||
background: white;
|
||
padding: 20px;
|
||
border-radius: 15px;
|
||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.filters h3 {
|
||
color: #667eea;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.filter-group {
|
||
display: flex;
|
||
gap: 15px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
|
||
.filter-group label {
|
||
font-weight: 600;
|
||
color: #333;
|
||
}
|
||
|
||
.filter-group select, .filter-group input {
|
||
padding: 8px 15px;
|
||
border: 2px solid #e0e0e0;
|
||
border-radius: 8px;
|
||
font-size: 1em;
|
||
outline: none;
|
||
transition: border-color 0.2s;
|
||
}
|
||
|
||
.filter-group select:focus, .filter-group input:focus {
|
||
border-color: #667eea;
|
||
}
|
||
|
||
.loading {
|
||
display: none;
|
||
text-align: center;
|
||
padding: 20px;
|
||
color: white;
|
||
font-size: 1.2em;
|
||
}
|
||
|
||
.hidden {
|
||
display: none;
|
||
}
|
||
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 5px 10px;
|
||
background: #667eea;
|
||
color: white;
|
||
border-radius: 15px;
|
||
font-size: 0.85em;
|
||
margin-left: 10px;
|
||
}
|
||
|
||
.nfc-badge {
|
||
background: #764ba2;
|
||
}
|
||
|
||
.trailer-badge {
|
||
background: #48bb78;
|
||
}
|
||
|
||
#results {
|
||
display: none;
|
||
}
|
||
|
||
/* Print Styles */
|
||
@media print {
|
||
body {
|
||
background: white;
|
||
padding: 0;
|
||
}
|
||
|
||
.container, .header, .upload-section, .loading,
|
||
#results, .detail-modal, #printBtn {
|
||
display: none !important;
|
||
}
|
||
|
||
#printReport {
|
||
display: block !important;
|
||
max-width: 100%;
|
||
padding: 20px;
|
||
}
|
||
|
||
.print-header {
|
||
text-align: center;
|
||
margin-bottom: 30px;
|
||
border-bottom: 3px solid #7c3aed;
|
||
padding-bottom: 20px;
|
||
}
|
||
|
||
.print-header h1 {
|
||
color: #7c3aed;
|
||
margin: 0;
|
||
font-size: 28px;
|
||
}
|
||
|
||
.print-summary, .print-charts, .print-scans, .print-trailers {
|
||
margin-bottom: 40px;
|
||
page-break-inside: avoid;
|
||
}
|
||
|
||
.print-summary h2, .print-charts h2,
|
||
.print-scans h2, .print-trailers h2 {
|
||
color: #7c3aed;
|
||
border-bottom: 2px solid #e9d5ff;
|
||
padding-bottom: 10px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.print-stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 15px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.print-stat-card {
|
||
border: 2px solid #e9d5ff;
|
||
padding: 15px;
|
||
text-align: center;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.print-stat-value {
|
||
font-size: 24px;
|
||
font-weight: bold;
|
||
color: #7c3aed;
|
||
}
|
||
|
||
.print-stat-label {
|
||
font-size: 12px;
|
||
color: #666;
|
||
margin-top: 5px;
|
||
}
|
||
|
||
.print-chart {
|
||
page-break-inside: avoid;
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.print-chart h3 {
|
||
color: #7c3aed;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.print-chart canvas {
|
||
max-width: 100%;
|
||
height: auto !important;
|
||
}
|
||
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 11px;
|
||
}
|
||
|
||
table th {
|
||
background: #7c3aed;
|
||
color: white;
|
||
padding: 8px;
|
||
text-align: left;
|
||
font-weight: bold;
|
||
}
|
||
|
||
table td {
|
||
padding: 6px 8px;
|
||
border-bottom: 1px solid #e9d5ff;
|
||
}
|
||
|
||
table tr:nth-child(even) {
|
||
background: #faf5ff;
|
||
}
|
||
|
||
.page-break {
|
||
page-break-before: always;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<h1>🎬 Video Player Log Analyzer</h1>
|
||
<p>Analyze NFC scans, video playback, and usage patterns</p>
|
||
</div>
|
||
|
||
<div class="upload-section">
|
||
<div class="file-input-wrapper">
|
||
<input type="file" id="fileInput" accept=".log,.txt">
|
||
<label class="file-input-label">📁 Load Log File</label>
|
||
</div>
|
||
<button onclick="printReport()" class="btn-primary" id="printBtn" style="display: none;">
|
||
🖨️ Print Report
|
||
</button>
|
||
<p style="margin-top: 15px; color: #666;">Upload your video_player_*.log file to begin analysis</p>
|
||
</div>
|
||
|
||
<div class="loading" id="loading">
|
||
<p>⏳ Analyzing log file...</p>
|
||
</div>
|
||
|
||
<div id="results">
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-value" id="totalNfcScans">0</div>
|
||
<div class="stat-label">NFC Scans</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value" id="totalVideos">0</div>
|
||
<div class="stat-label">Unique Videos</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value" id="totalTrailers">0</div>
|
||
<div class="stat-label">Trailers Played</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value" id="avgTimeBetween">0s</div>
|
||
<div class="stat-label">Avg. Time Between Scans</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filters">
|
||
<h3>📊 Chart Options</h3>
|
||
<div class="filter-group">
|
||
<label>View:</label>
|
||
<select id="chartType">
|
||
<option value="timeline">Timeline (All Events)</option>
|
||
<option value="hourly">Hourly Activity</option>
|
||
<option value="videoCount">Video Play Count</option>
|
||
<option value="timeBetween">Time Between Scans</option>
|
||
</select>
|
||
<label style="margin-left: 20px;">Filter:</label>
|
||
<select id="eventFilter">
|
||
<option value="all">All Events</option>
|
||
<option value="nfc">NFC Scans Only</option>
|
||
<option value="trailers">Trailers Only</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chart-container">
|
||
<canvas id="mainChart"></canvas>
|
||
</div>
|
||
|
||
<div class="video-list">
|
||
<h2>🎥 Video Playback Details</h2>
|
||
<div id="videoListContainer"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="detail-modal" id="detailModal">
|
||
<div class="detail-content">
|
||
<span class="close-btn" onclick="closeDetail()">×</span>
|
||
<h2 class="detail-header" id="detailTitle"></h2>
|
||
<div id="detailStats"></div>
|
||
<div class="playback-timeline" id="detailTimeline"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Print Report (hidden, only visible when printing) -->
|
||
<div id="printReport" style="display: none;">
|
||
<div class="print-header">
|
||
<h1>🎬 Video Player Activity Report</h1>
|
||
<p id="printReportDate"></p>
|
||
</div>
|
||
|
||
<div class="print-summary">
|
||
<h2>Summary Statistics</h2>
|
||
<div id="printStats"></div>
|
||
</div>
|
||
|
||
<div class="print-charts">
|
||
<h2>Activity Charts</h2>
|
||
<div id="printChartsContainer"></div>
|
||
</div>
|
||
|
||
<div class="print-scans">
|
||
<h2>NFC Scan Details</h2>
|
||
<table id="printScansTable"></table>
|
||
</div>
|
||
|
||
<div class="print-trailers">
|
||
<h2>Trailer Playback Details</h2>
|
||
<table id="printTrailersTable"></table>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let logData = {
|
||
nfcScans: [],
|
||
videoPlays: [],
|
||
trailers: [],
|
||
allEvents: []
|
||
};
|
||
|
||
let currentChart = null;
|
||
|
||
document.getElementById('fileInput').addEventListener('change', handleFileSelect);
|
||
document.getElementById('chartType').addEventListener('change', updateChart);
|
||
document.getElementById('eventFilter').addEventListener('change', updateChart);
|
||
|
||
function handleFileSelect(event) {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
|
||
document.getElementById('loading').style.display = 'block';
|
||
document.getElementById('results').style.display = 'none';
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = function(e) {
|
||
const content = e.target.result;
|
||
setTimeout(() => {
|
||
parseLogFile(content);
|
||
displayResults();
|
||
document.getElementById('loading').style.display = 'none';
|
||
document.getElementById('results').style.display = 'block';
|
||
}, 100);
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
function parseLogFile(content) {
|
||
const lines = content.split('\n');
|
||
logData = {
|
||
nfcScans: [],
|
||
videoPlays: [],
|
||
trailers: [],
|
||
allEvents: []
|
||
};
|
||
|
||
// First pass: Parse NFC mappings from startup configuration
|
||
const nfcMappings = {};
|
||
const singleFilePattern = /Mapped NFC (\d+) -> (.+)/;
|
||
const folderPattern = /Mapped NFC (\d+) -> Folder '(.+?)' \((\d+) videos?\)/;
|
||
|
||
for (const line of lines) {
|
||
const folderMatch = line.match(folderPattern);
|
||
const singleMatch = line.match(singleFilePattern);
|
||
|
||
if (folderMatch) {
|
||
const [, nfcId, folderName, videoCount] = folderMatch;
|
||
nfcMappings[nfcId] = `Folder '${folderName}' (${videoCount} videos)`;
|
||
} else if (singleMatch && !line.includes('Folder')) {
|
||
const [, nfcId, videoName] = singleMatch;
|
||
nfcMappings[nfcId] = videoName.trim();
|
||
}
|
||
}
|
||
|
||
// Updated patterns to match actual runtime scan events
|
||
const nfcScanPattern = /(\d{2}:\d{2}:\d{2}\.\d{3}).*Processing NFC input: (\d+)/;
|
||
const folderVideoPattern = /(\d{2}:\d{2}:\d{2}\.\d{3}).*Folder '(.+?)': Playing video (\d+)\/(\d+)/;
|
||
const queuedSpecificPattern = /(\d{2}:\d{2}:\d{2}\.\d{3}).*Queued specific video: (.+)/;
|
||
const queuedFolderPattern = /(\d{2}:\d{2}:\d{2}\.\d{3}).*Queued folder video: (.+)/;
|
||
const trailerPattern = /(\d{2}:\d{2}:\d{2}\.\d{3}).*Selected trailer: (.+)/;
|
||
|
||
let lastNfcScan = null;
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
|
||
// NFC Scan detection (actual runtime scans)
|
||
const nfcMatch = line.match(nfcScanPattern);
|
||
if (nfcMatch) {
|
||
const [, time, nfcId] = nfcMatch;
|
||
|
||
// Look up the video/folder name from the mappings
|
||
const videoName = nfcMappings[nfcId] || `Unknown (NFC ${nfcId})`;
|
||
|
||
const event = {
|
||
time: time,
|
||
timestamp: parseTime(time),
|
||
nfcId: nfcId,
|
||
video: videoName,
|
||
type: 'nfc'
|
||
};
|
||
logData.nfcScans.push(event);
|
||
logData.allEvents.push(event);
|
||
lastNfcScan = event;
|
||
}
|
||
|
||
// Trailer detection
|
||
const trailerMatch = line.match(trailerPattern);
|
||
if (trailerMatch) {
|
||
const [, time, trailer] = trailerMatch;
|
||
const event = {
|
||
time: time,
|
||
timestamp: parseTime(time),
|
||
video: trailer.trim(),
|
||
type: 'trailer'
|
||
};
|
||
logData.trailers.push(event);
|
||
logData.allEvents.push(event);
|
||
}
|
||
|
||
// Video play events for counting (both NFC-triggered and trailers)
|
||
const specificMatch = line.match(queuedSpecificPattern);
|
||
const folderMatch = line.match(queuedFolderPattern);
|
||
if (specificMatch || folderMatch) {
|
||
const videoName = (specificMatch ? specificMatch[2] : folderMatch[2]).trim();
|
||
const time = (specificMatch ? specificMatch[1] : folderMatch[1]);
|
||
|
||
// Determine if this is tied to an NFC scan or is a trailer
|
||
const isNfcTriggered = lastNfcScan &&
|
||
(parseTime(time) - lastNfcScan.timestamp < 5000); // Within 5 seconds
|
||
|
||
if (isNfcTriggered) {
|
||
const event = {
|
||
time: time,
|
||
timestamp: parseTime(time),
|
||
video: videoName,
|
||
type: 'video'
|
||
};
|
||
logData.videoPlays.push(event);
|
||
logData.allEvents.push(event);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Sort all events by timestamp
|
||
logData.allEvents.sort((a, b) => a.timestamp - b.timestamp);
|
||
}
|
||
|
||
function parseTime(timeStr) {
|
||
const [hours, minutes, seconds] = timeStr.split(':');
|
||
const [secs, ms] = seconds.split('.');
|
||
return (parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(secs)) * 1000 + parseInt(ms);
|
||
}
|
||
|
||
function formatTime(ms) {
|
||
const totalSeconds = Math.floor(ms / 1000);
|
||
const hours = Math.floor(totalSeconds / 3600);
|
||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||
const seconds = totalSeconds % 60;
|
||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||
}
|
||
|
||
function formatDuration(ms) {
|
||
const totalSeconds = Math.floor(ms / 1000);
|
||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||
const minutes = Math.floor(totalSeconds / 60);
|
||
const seconds = totalSeconds % 60;
|
||
if (minutes < 60) return `${minutes}m ${seconds}s`;
|
||
const hours = Math.floor(minutes / 60);
|
||
const mins = minutes % 60;
|
||
return `${hours}h ${mins}m`;
|
||
}
|
||
|
||
function displayResults() {
|
||
// Show print button
|
||
document.getElementById('printBtn').style.display = 'inline-block';
|
||
|
||
// Update stats
|
||
document.getElementById('totalNfcScans').textContent = logData.nfcScans.length;
|
||
|
||
const uniqueVideos = new Set([
|
||
...logData.videoPlays.map(v => v.video),
|
||
...logData.nfcScans.map(v => v.video)
|
||
]);
|
||
document.getElementById('totalVideos').textContent = uniqueVideos.size;
|
||
document.getElementById('totalTrailers').textContent = logData.trailers.length;
|
||
|
||
// Calculate average time between NFC scans
|
||
if (logData.nfcScans.length > 1) {
|
||
let totalTime = 0;
|
||
for (let i = 1; i < logData.nfcScans.length; i++) {
|
||
totalTime += logData.nfcScans[i].timestamp - logData.nfcScans[i-1].timestamp;
|
||
}
|
||
const avgTime = totalTime / (logData.nfcScans.length - 1);
|
||
document.getElementById('avgTimeBetween').textContent = formatDuration(avgTime);
|
||
}
|
||
|
||
// Create video list
|
||
createVideoList();
|
||
|
||
// Create chart
|
||
updateChart();
|
||
}
|
||
|
||
function createVideoList() {
|
||
const container = document.getElementById('videoListContainer');
|
||
container.innerHTML = '';
|
||
|
||
// Count plays per video
|
||
const videoCounts = {};
|
||
[...logData.nfcScans, ...logData.videoPlays].forEach(event => {
|
||
const video = event.video;
|
||
if (!videoCounts[video]) {
|
||
videoCounts[video] = {
|
||
name: video,
|
||
count: 0,
|
||
nfcCount: 0,
|
||
events: []
|
||
};
|
||
}
|
||
videoCounts[video].count++;
|
||
if (event.type === 'nfc') videoCounts[video].nfcCount++;
|
||
videoCounts[video].events.push(event);
|
||
});
|
||
|
||
// Sort by play count
|
||
const sortedVideos = Object.values(videoCounts).sort((a, b) => b.count - a.count);
|
||
|
||
sortedVideos.forEach(video => {
|
||
const item = document.createElement('div');
|
||
item.className = 'video-item';
|
||
item.onclick = () => showVideoDetail(video);
|
||
|
||
const badges = video.nfcCount > 0 ?
|
||
`<span class="badge nfc-badge">NFC: ${video.nfcCount}</span>` : '';
|
||
|
||
item.innerHTML = `
|
||
<div class="video-name">${video.name} ${badges}</div>
|
||
<div class="video-stats">
|
||
Played ${video.count} time${video.count !== 1 ? 's' : ''}
|
||
</div>
|
||
`;
|
||
container.appendChild(item);
|
||
});
|
||
}
|
||
|
||
function showVideoDetail(video) {
|
||
document.getElementById('detailTitle').textContent = video.name;
|
||
|
||
const stats = `
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-value">${video.count}</div>
|
||
<div class="stat-label">Total Plays</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">${video.nfcCount}</div>
|
||
<div class="stat-label">NFC Scans</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.getElementById('detailStats').innerHTML = stats;
|
||
|
||
const timeline = document.getElementById('detailTimeline');
|
||
timeline.innerHTML = '<h3 style="color: #667eea; margin-bottom: 15px;">Playback Timeline</h3>';
|
||
|
||
video.events.forEach((event, index) => {
|
||
const item = document.createElement('div');
|
||
item.className = 'timeline-item';
|
||
|
||
let timeBetween = '';
|
||
if (index > 0) {
|
||
const timeDiff = event.timestamp - video.events[index-1].timestamp;
|
||
timeBetween = `<br><small style="color: #999;">+${formatDuration(timeDiff)} since last play</small>`;
|
||
}
|
||
|
||
const typeLabel = event.type === 'nfc' ?
|
||
'<span class="badge nfc-badge">NFC Scan</span>' :
|
||
'<span class="badge">Auto-play</span>';
|
||
|
||
item.innerHTML = `
|
||
<div class="timeline-time">${event.time} ${typeLabel}</div>
|
||
<div class="timeline-info">
|
||
${event.nfcId ? `NFC ID: ${event.nfcId}` : 'Automatic playback'}
|
||
${timeBetween}
|
||
</div>
|
||
`;
|
||
timeline.appendChild(item);
|
||
});
|
||
|
||
document.getElementById('detailModal').style.display = 'block';
|
||
}
|
||
|
||
function closeDetail() {
|
||
document.getElementById('detailModal').style.display = 'none';
|
||
}
|
||
|
||
function updateChart() {
|
||
const chartType = document.getElementById('chartType').value;
|
||
const eventFilter = document.getElementById('eventFilter').value;
|
||
|
||
if (currentChart) {
|
||
currentChart.destroy();
|
||
}
|
||
|
||
let filteredEvents = logData.allEvents;
|
||
if (eventFilter === 'nfc') {
|
||
filteredEvents = logData.nfcScans;
|
||
} else if (eventFilter === 'trailers') {
|
||
filteredEvents = logData.trailers;
|
||
}
|
||
|
||
const ctx = document.getElementById('mainChart').getContext('2d');
|
||
|
||
switch (chartType) {
|
||
case 'timeline':
|
||
createTimelineChart(ctx, filteredEvents);
|
||
break;
|
||
case 'hourly':
|
||
createHourlyChart(ctx, filteredEvents);
|
||
break;
|
||
case 'videoCount':
|
||
createVideoCountChart(ctx, filteredEvents);
|
||
break;
|
||
case 'timeBetween':
|
||
createTimeBetweenChart(ctx);
|
||
break;
|
||
}
|
||
}
|
||
|
||
function createTimelineChart(ctx, events) {
|
||
const data = events.map(event => ({
|
||
x: event.timestamp,
|
||
y: 1,
|
||
label: event.video,
|
||
type: event.type
|
||
}));
|
||
|
||
currentChart = new Chart(ctx, {
|
||
type: 'scatter',
|
||
data: {
|
||
datasets: [{
|
||
label: 'NFC Scans',
|
||
data: data.filter(d => d.type === 'nfc'),
|
||
backgroundColor: 'rgba(118, 75, 162, 0.8)',
|
||
pointRadius: 8,
|
||
pointHoverRadius: 12
|
||
}, {
|
||
label: 'Videos',
|
||
data: data.filter(d => d.type === 'video'),
|
||
backgroundColor: 'rgba(102, 126, 234, 0.8)',
|
||
pointRadius: 6,
|
||
pointHoverRadius: 10
|
||
}, {
|
||
label: 'Trailers',
|
||
data: data.filter(d => d.type === 'trailer'),
|
||
backgroundColor: 'rgba(72, 187, 120, 0.8)',
|
||
pointRadius: 4,
|
||
pointHoverRadius: 8
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
title: {
|
||
display: true,
|
||
text: 'Event Timeline',
|
||
font: { size: 18, weight: 'bold' }
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
label: function(context) {
|
||
const point = context.raw;
|
||
return `${point.label} (${formatTime(point.x)})`;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
type: 'linear',
|
||
title: {
|
||
display: true,
|
||
text: 'Time'
|
||
},
|
||
ticks: {
|
||
callback: function(value) {
|
||
return formatTime(value);
|
||
}
|
||
}
|
||
},
|
||
y: {
|
||
display: false
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function createHourlyChart(ctx, events) {
|
||
const hourCounts = new Array(24).fill(0);
|
||
|
||
events.forEach(event => {
|
||
const hour = Math.floor(event.timestamp / (3600 * 1000));
|
||
if (hour < 24) hourCounts[hour]++;
|
||
});
|
||
|
||
currentChart = new Chart(ctx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: hourCounts.map((_, i) => `${String(i).padStart(2, '0')}:00`),
|
||
datasets: [{
|
||
label: 'Events per Hour',
|
||
data: hourCounts,
|
||
backgroundColor: 'rgba(102, 126, 234, 0.8)',
|
||
borderColor: 'rgba(102, 126, 234, 1)',
|
||
borderWidth: 2
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
title: {
|
||
display: true,
|
||
text: 'Hourly Activity',
|
||
font: { size: 18, weight: 'bold' }
|
||
}
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: 'Number of Events'
|
||
}
|
||
},
|
||
x: {
|
||
title: {
|
||
display: true,
|
||
text: 'Hour of Day'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function createVideoCountChart(ctx, events) {
|
||
const videoCounts = {};
|
||
events.forEach(event => {
|
||
videoCounts[event.video] = (videoCounts[event.video] || 0) + 1;
|
||
});
|
||
|
||
const sorted = Object.entries(videoCounts)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 15);
|
||
|
||
currentChart = new Chart(ctx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: sorted.map(([name]) => name.length > 30 ? name.substring(0, 27) + '...' : name),
|
||
datasets: [{
|
||
label: 'Play Count',
|
||
data: sorted.map(([, count]) => count),
|
||
backgroundColor: 'rgba(118, 75, 162, 0.8)',
|
||
borderColor: 'rgba(118, 75, 162, 1)',
|
||
borderWidth: 2
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
indexAxis: 'y',
|
||
plugins: {
|
||
title: {
|
||
display: true,
|
||
text: 'Top 15 Most Played Videos',
|
||
font: { size: 18, weight: 'bold' }
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: 'Number of Plays'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function createTimeBetweenChart(ctx) {
|
||
if (logData.nfcScans.length < 2) {
|
||
ctx.font = '20px Arial';
|
||
ctx.fillStyle = '#666';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Not enough NFC scan data', ctx.canvas.width / 2, ctx.canvas.height / 2);
|
||
return;
|
||
}
|
||
|
||
const timeBetween = [];
|
||
for (let i = 1; i < logData.nfcScans.length; i++) {
|
||
timeBetween.push({
|
||
x: i,
|
||
y: (logData.nfcScans[i].timestamp - logData.nfcScans[i-1].timestamp) / 1000
|
||
});
|
||
}
|
||
|
||
currentChart = new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
datasets: [{
|
||
label: 'Time Between Scans (seconds)',
|
||
data: timeBetween,
|
||
borderColor: 'rgba(102, 126, 234, 1)',
|
||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||
fill: true,
|
||
tension: 0.4
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
title: {
|
||
display: true,
|
||
text: 'Time Between Consecutive NFC Scans',
|
||
font: { size: 18, weight: 'bold' }
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
type: 'linear',
|
||
title: {
|
||
display: true,
|
||
text: 'Scan Number'
|
||
}
|
||
},
|
||
y: {
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: 'Seconds'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function printReport() {
|
||
try {
|
||
console.log('Print Report clicked');
|
||
console.log('logData:', logData);
|
||
|
||
// Generate report date
|
||
const now = new Date();
|
||
document.getElementById('printReportDate').textContent =
|
||
`Generated: ${now.toLocaleDateString()} ${now.toLocaleTimeString()}`;
|
||
|
||
// Generate summary statistics
|
||
const statsHtml = `
|
||
<div class="print-stats-grid">
|
||
<div class="print-stat-card">
|
||
<div class="print-stat-value">${logData.nfcScans.length}</div>
|
||
<div class="print-stat-label">Total NFC Scans</div>
|
||
</div>
|
||
<div class="print-stat-card">
|
||
<div class="print-stat-value">${logData.videoPlays.length}</div>
|
||
<div class="print-stat-label">Videos Played</div>
|
||
</div>
|
||
<div class="print-stat-card">
|
||
<div class="print-stat-value">${logData.trailers.length}</div>
|
||
<div class="print-stat-label">Trailers Played</div>
|
||
</div>
|
||
<div class="print-stat-card">
|
||
<div class="print-stat-value">${document.getElementById('avgTimeBetween').textContent}</div>
|
||
<div class="print-stat-label">Avg Time Between Scans</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.getElementById('printStats').innerHTML = statsHtml;
|
||
console.log('Stats HTML generated');
|
||
|
||
// Generate charts
|
||
const chartsContainer = document.getElementById('printChartsContainer');
|
||
chartsContainer.innerHTML = '';
|
||
console.log('Charts container cleared');
|
||
|
||
// Create print versions of all charts
|
||
const chartTypes = [
|
||
{ type: 'timeline', title: 'Event Timeline', height: 200 },
|
||
{ type: 'hourly', title: 'Hourly Activity', height: 300 },
|
||
{ type: 'video-count', title: 'Top 15 Videos', height: 400 },
|
||
{ type: 'time-between', title: 'Time Between Scans', height: 300 }
|
||
];
|
||
|
||
chartTypes.forEach(({ type, title, height }) => {
|
||
const chartDiv = document.createElement('div');
|
||
chartDiv.className = 'print-chart';
|
||
chartDiv.innerHTML = `<h3>${title}</h3>`;
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 800;
|
||
canvas.height = height;
|
||
chartDiv.appendChild(canvas);
|
||
chartsContainer.appendChild(chartDiv);
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// Get filtered events based on current filter
|
||
const filterValue = document.getElementById('eventFilter').value;
|
||
let events = logData.allEvents;
|
||
if (filterValue === 'nfc') {
|
||
events = logData.nfcScans;
|
||
} else if (filterValue === 'trailers') {
|
||
events = logData.trailers;
|
||
}
|
||
|
||
// Generate chart based on type
|
||
switch(type) {
|
||
case 'timeline':
|
||
createTimelineChart(ctx, events);
|
||
break;
|
||
case 'hourly':
|
||
createHourlyChart(ctx, events);
|
||
break;
|
||
case 'video-count':
|
||
createVideoCountChart(ctx, events);
|
||
break;
|
||
case 'time-between':
|
||
createTimeBetweenChart(ctx, logData.nfcScans);
|
||
break;
|
||
}
|
||
});
|
||
|
||
// Generate NFC scans table
|
||
let scansTableHtml = `
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>Time</th>
|
||
<th>NFC ID</th>
|
||
<th>Video/Folder</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
`;
|
||
|
||
logData.nfcScans.forEach((scan, index) => {
|
||
scansTableHtml += `
|
||
<tr>
|
||
<td>${index + 1}</td>
|
||
<td>${scan.time}</td>
|
||
<td>${scan.nfcId}</td>
|
||
<td>${scan.video}</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
scansTableHtml += '</tbody>';
|
||
document.getElementById('printScansTable').innerHTML = scansTableHtml;
|
||
|
||
// Generate trailers table
|
||
let trailersTableHtml = `
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>Time</th>
|
||
<th>Trailer</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
`;
|
||
|
||
logData.trailers.forEach((trailer, index) => {
|
||
trailersTableHtml += `
|
||
<tr>
|
||
<td>${index + 1}</td>
|
||
<td>${trailer.time}</td>
|
||
<td>${trailer.video}</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
trailersTableHtml += '</tbody>';
|
||
document.getElementById('printTrailersTable').innerHTML = trailersTableHtml;
|
||
console.log('Tables generated successfully');
|
||
|
||
// Trigger print dialog
|
||
console.log('Triggering print dialog in 500ms...');
|
||
setTimeout(() => {
|
||
console.log('Opening print dialog now');
|
||
window.print();
|
||
}, 500); // Give charts time to render
|
||
|
||
} catch (error) {
|
||
console.error('Error in printReport:', error);
|
||
alert('Error generating print report: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// Close modal when clicking outside
|
||
window.onclick = function(event) {
|
||
const modal = document.getElementById('detailModal');
|
||
if (event.target === modal) {
|
||
modal.style.display = 'none';
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|