diff --git a/web/ajax/console.php b/web/ajax/console.php index ffaddccf2..9322a7979 100644 --- a/web/ajax/console.php +++ b/web/ajax/console.php @@ -460,7 +460,7 @@ function queryRequest() { 'replay' => 'single', 'rate' => '400'), '&'); if ($event->DefaultVideo()) { $videoSrc = $event->getStreamSrc(array('mode' => 'mp4'), '&'); - $videoAttr = ' video_src="'.$videoSrc.'"'; + $videoAttr = ' video_src="'.$videoSrc.'" data-event-start="'.htmlspecialchars($event->StartDateTime()).'"'; } } } else { diff --git a/web/ajax/events.php b/web/ajax/events.php index 89a6520de..f8f684c83 100644 --- a/web/ajax/events.php +++ b/web/ajax/events.php @@ -328,7 +328,7 @@ function queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $lim $videoAttr = ''; if ($event->DefaultVideo()) { $videoSrc = $event->getStreamSrc(array('mode'=>'mp4'), '&'); - $videoAttr = ' video_src="' .$videoSrc. '"'; + $videoAttr = ' video_src="' .$videoSrc. '" data-event-start="'.htmlspecialchars($event->StartDateTime()).'"'; } // Modify the row data as needed diff --git a/web/skins/classic/css/base/skin.css b/web/skins/classic/css/base/skin.css index 3485e3d69..f7867532b 100644 --- a/web/skins/classic/css/base/skin.css +++ b/web/skins/classic/css/base/skin.css @@ -919,6 +919,67 @@ li.search-choice { object-fit: cover; } +.thumb-overlay-wrapper { + display: flex; + flex-direction: column; + align-items: center; +} + +.thumb-overlay-status { + background: rgba(0, 0, 0, 0.8); + color: #fff; + padding: 8px 16px; + margin-top: 8px; + border-radius: 4px; + font-family: monospace; + font-size: 14px; + min-height: 20px; +} + +.live-indicator { + display: flex; + align-items: center; + gap: 8px; + font-weight: bold; + color: #ff4444; + text-transform: uppercase; + letter-spacing: 1px; +} + +.live-dot { + width: 10px; + height: 10px; + background: #ff4444; + border-radius: 50%; + animation: live-pulse 1.5s ease-in-out infinite; +} + +@keyframes live-pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(1.2); + } +} + +.time-indicator { + display: flex; + align-items: center; + gap: 8px; + color: #ccc; +} + +.time-indicator .fa { + color: #888; +} + +.time-display { + font-variant-numeric: tabular-nums; +} + a.flip { float: right; padding-right: 5px; diff --git a/web/skins/classic/js/skin.js b/web/skins/classic/js/skin.js index 5a5cee5ea..d8d1310d2 100644 --- a/web/skins/classic/js/skin.js +++ b/web/skins/classic/js/skin.js @@ -1297,6 +1297,10 @@ function createThumbnailOverlay(img, overlaySrc, dimensions, streamType, monitor const overlay = document.createElement('div'); overlay.id = 'thumb-overlay'; + // Wrapper contains video container and status bar + const wrapper = document.createElement('div'); + wrapper.className = 'thumb-overlay-wrapper'; + // Container uses cached still image as background while stream loads const container = document.createElement('div'); container.id = 'monitor-thumb-overlay'; // video-stream.js expects parent with id starting with "monitor" @@ -1314,25 +1318,62 @@ function createThumbnailOverlay(img, overlaySrc, dimensions, streamType, monitor } }; - if (overlaySrc === 'live' && useGo2rtc) { + // Determine if this is a live stream or recorded video + const isLive = (overlaySrc === 'live'); + const eventStart = img.dataset.eventStart; + + // Create status bar (only if there's content to show) + let statusBar = null; + if (isLive || eventStart) { + statusBar = document.createElement('div'); + statusBar.className = 'thumb-overlay-status'; + + if (isLive) { + // Live indicator with pulsing dot + statusBar.innerHTML = 'LIVE'; + } else if (eventStart) { + // Wall clock time for recorded video with clock icon + statusBar.innerHTML = '' + + formatDateTime(new Date(eventStart)) + ''; + } + } + + if (isLive && useGo2rtc) { createGo2rtcStream(container, go2rtcSrc, monitorId || go2rtcMid, fallbackToMjpeg); } else if (streamType === 'rtsp2web') { createRtsp2webStream(container, img, monitorId, fallbackToMjpeg); } else if (streamType === 'janus') { // Janus requires complex initialization; fall back to MJPEG fallbackToMjpeg(); - } else if (overlaySrc !== 'live' && img.getAttribute('video_src') && currentView !== 'frames') { - createVideoElement(container, overlaySrc); + } else if (!isLive && img.getAttribute('video_src') && currentView !== 'frames') { + createVideoElement(container, overlaySrc, eventStart, statusBar); } else { const overlayImg = document.createElement('img'); overlayImg.src = overlaySrc; container.appendChild(overlayImg); } - overlay.appendChild(container); + wrapper.appendChild(container); + if (statusBar) wrapper.appendChild(statusBar); + overlay.appendChild(wrapper); document.body.appendChild(overlay); } +// Format date/time for display in status bar +function formatDateTime(date) { + if (!(date instanceof Date) || isNaN(date)) return ''; + const options = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }; + return date.toLocaleString(undefined, options); +} + function createGo2rtcStream(container, src, mid, fallbackToMjpeg) { ensureVideoStreamLoaded().then(function() { if (!document.getElementById('thumb-overlay')) return; @@ -1410,7 +1451,7 @@ function createRtsp2webStream(container, img, monitorId, fallbackToMjpeg) { }).catch(fallbackToMjpeg); } -function createVideoElement(container, src) { +function createVideoElement(container, src, eventStart, statusBar) { const video = document.createElement('video'); const previewRate = getPreviewRate(); video.src = src; @@ -1421,6 +1462,19 @@ function createVideoElement(container, src) { video.addEventListener('loadedmetadata', function() { this.playbackRate = previewRate; // Some browsers reset playbackRate on metadata load }); + + // Update wall clock time as video plays + if (eventStart && statusBar) { + const startTime = new Date(eventStart).getTime(); + const timeDisplay = statusBar.querySelector('.time-display'); + if (timeDisplay && !isNaN(startTime)) { + video.addEventListener('timeupdate', function() { + const currentTime = startTime + (video.currentTime * 1000); + timeDisplay.textContent = formatDateTime(new Date(currentTime)); + }); + } + } + container.appendChild(video); }