mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-03-26 01:32:29 -04:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user