feat: add status bar to thumbnail overlay with LIVE indicator and wall clock time

- Add status bar below video with LIVE indicator for live streams
- Show pulsing red dot animation for live streams
- Display wall clock time that updates as recorded video plays
- Add event start time data attribute to console and events pages
- Hide status bar when no content to display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ben Dailey
2026-01-28 21:09:57 -05:00
parent d51790531a
commit fd5bd63808
4 changed files with 122 additions and 7 deletions

View File

@@ -460,7 +460,7 @@ function queryRequest() {
'replay' => 'single', 'rate' => '400'), '&amp;');
if ($event->DefaultVideo()) {
$videoSrc = $event->getStreamSrc(array('mode' => 'mp4'), '&amp;');
$videoAttr = ' video_src="'.$videoSrc.'"';
$videoAttr = ' video_src="'.$videoSrc.'" data-event-start="'.htmlspecialchars($event->StartDateTime()).'"';
}
}
} else {

View File

@@ -328,7 +328,7 @@ function queryRequest($filter, $search, $advsearch, $sort, $offset, $order, $lim
$videoAttr = '';
if ($event->DefaultVideo()) {
$videoSrc = $event->getStreamSrc(array('mode'=>'mp4'), '&amp;');
$videoAttr = ' video_src="' .$videoSrc. '"';
$videoAttr = ' video_src="' .$videoSrc. '" data-event-start="'.htmlspecialchars($event->StartDateTime()).'"';
}
// Modify the row data as needed

View File

@@ -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;

View File

@@ -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 = '<span class="live-indicator"><span class="live-dot"></span>LIVE</span>';
} else if (eventStart) {
// Wall clock time for recorded video with clock icon
statusBar.innerHTML = '<span class="time-indicator"><i class="fa fa-clock-o"></i><span class="time-display">' +
formatDateTime(new Date(eventStart)) + '</span></span>';
}
}
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);
}