Keep live players stable across polls and automatically recover each stream
This commit is contained in:
+17
-20
@@ -53,30 +53,27 @@ function render() {
|
||||
function empty(title, description, button) { return `<div class="empty"><h3>${esc(title)}</h3><p>${esc(description)}</p>${button || ''}</div>`; }
|
||||
function renderLive() {
|
||||
const cameras = filtered(), quality = $('#quality').value;
|
||||
const key = JSON.stringify(cameras.map(c=>[c.id,c.name,c.siteId,c.point,c.ready,c.enabled,c.record,c.recordingActive])) + quality;
|
||||
if (key === liveKey) return;
|
||||
const key = ZhaoLivePlayer.key(cameras, quality);
|
||||
if (key === liveKey) { updateLiveStatus(cameras); return; }
|
||||
stopPlayers(); liveKey = key;
|
||||
$('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`<article class="camera-card"><div class="video-panel"><video data-camera="${c.id}" muted playsinline controls></video><div class="video-empty"><span class="lens">◎</span><span>${c.enabled ? '正在等待画面' : '设备连接已停用'}</span></div><div class="video-error" hidden></div></div><div class="camera-meta"><div><strong>${esc(c.name)}</strong><p>${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}</p></div><span class="badge ${c.ready?'':'off'}">${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}</span></div><div class="camera-actions"><button class="quiet" data-edit-camera="${c.id}">设备配置</button><button class="quiet" data-play-camera="${c.id}">查看录像</button></div></article>`).join('') : empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。','<button data-action="new-camera">+ 添加摄像头</button>');
|
||||
for (const c of cameras.filter(c=>c.enabled)) {
|
||||
const video = $(`video[data-camera="${c.id}"]`), panel = video.parentElement;
|
||||
const src = `/media/live/cam_${c.id}_${quality}/index.m3u8`;
|
||||
const error = text => {const el=panel.querySelector('.video-error');el.textContent=text;el.hidden=false;};
|
||||
video.onplaying=()=>{panel.querySelector('.video-empty').hidden=true;panel.querySelector('.video-error').hidden=true;};
|
||||
video.onerror=()=>error('画面解码失败,请切换主码流或子码流,或点击刷新状态重试。');
|
||||
if (window.Hls?.isSupported()) {
|
||||
const hls = new Hls({maxBufferLength:10,backBufferLength:5,liveSyncDurationCount:3,manifestLoadingTimeOut:60000,levelLoadingTimeOut:60000,manifestLoadingMaxRetry:2,levelLoadingMaxRetry:2});
|
||||
hls.on(Hls.Events.ERROR,(_,data)=>{if(data.fatal){
|
||||
const codecError = /codec|bufferAddCodec|manifestIncompatible/i.test(data.details || '');
|
||||
error(codecError ? '画面解码失败,请切换主码流或子码流,或点击刷新状态重试。' : '画面加载失败('+(data.details || data.type)+')。点击刷新状态可重试。');
|
||||
console.warn('Video playback failed', c.id, data.type, data.details);
|
||||
hls.destroy();
|
||||
}});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED,()=>video.play().catch(()=>{}));
|
||||
hls.loadSource(src);hls.attachMedia(video);players.push(hls);
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {video.src=src;video.play().catch(()=>{});}
|
||||
else error('当前浏览器不支持 HLS 播放,请使用较新的浏览器。');
|
||||
const video = $(`video[data-camera="${c.id}"]`);
|
||||
players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`));
|
||||
}
|
||||
}
|
||||
function updateLiveStatus(cameras) {
|
||||
for (const c of cameras) {
|
||||
const card=$(`video[data-camera="${c.id}"]`)?.closest('.camera-card');
|
||||
if (!card) continue;
|
||||
card.querySelector('.camera-meta strong').textContent=c.name;
|
||||
card.querySelector('.camera-meta p').textContent=siteName(c.siteId)+' · '+(c.point || '安装位置待填写');
|
||||
const badge=card.querySelector('.badge');
|
||||
badge.textContent=c.recordingActive?'● 正在录像':c.ready?'码流已接通':c.enabled?'等待接通':'已停用';
|
||||
badge.classList.toggle('off',!c.ready);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCameras() {
|
||||
$('#scan-button').disabled=state.scan.running; $('#scan-button').textContent=state.scan.running?'正在探测…':'开始探测';
|
||||
$('#scan-result').innerHTML=(state.scan.items||[]).map(s=>`<div class="scan-chip"><span>${esc(s.host)}<small> · RTSP 服务响应</small></span><button data-found="${esc(s.host)}">添加</button></div>`).join('') || (state.scan.at ? '<p class="description">本次没有发现响应 RTSP 554 端口的设备。</p>' : '');
|
||||
@@ -106,7 +103,7 @@ $('#auth-form').addEventListener('submit',async e=>{e.preventDefault();const f=e
|
||||
$('#auth-dialog').addEventListener('cancel',e=>e.preventDefault());
|
||||
document.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.page)showPage(b.dataset.page);if(b.dataset.close)$(`#${b.dataset.close}`).close();if(b.dataset.action==='new-camera')openCamera();if(b.dataset.action==='new-asset')openAsset();if(b.dataset.action==='new-recorder')openRecorder();if(b.dataset.editAsset)openAsset(b.dataset.editAsset);if(b.dataset.editRecorder)openRecorder(b.dataset.editRecorder);if(b.dataset.action==='new-site')openSite();if(b.dataset.editSite)openSite(b.dataset.editSite);if(b.dataset.editCamera)openCamera(b.dataset.editCamera);if(b.dataset.found)openCamera(null,b.dataset.found);if(b.dataset.playCamera){showPage('playback');$('#play-camera').value=b.dataset.playCamera;$('#archive-source').value=state.cameras.find(c=>c.id===b.dataset.playCamera)?.archiveSource||'local';queryRecordings();}if(b.dataset.span)playAt(new Date(b.dataset.span));});
|
||||
$('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;stopPlayers();resetPlayback();await auth();};
|
||||
$('#refresh').onclick=()=>{liveKey='';refresh();};$('#site-filter').onchange=()=>{stopPlayers();resetPlayback();render();};$('#quality').onchange=()=>{stopPlayers();renderLive();};
|
||||
$('#refresh').onclick=()=>{players.forEach(p=>p.retryIfNeeded());refresh();};$('#site-filter').onchange=()=>{stopPlayers();resetPlayback();render();};$('#quality').onchange=()=>{stopPlayers();renderLive();};
|
||||
$('#scan-button').onclick=async()=>{try{await api('/api/discover',{network:$('#scan-network').value});await refresh();}catch(e){toast(e.message);}};
|
||||
$('#path-preset').onchange=e=>{const presets={tplink:['/stream1','/stream2'],hikvision:['/Streaming/Channels/101','/Streaming/Channels/102'],dahua:['/cam/realmonitor?channel=1&subtype=0','/cam/realmonitor?channel=1&subtype=1']};const v=presets[e.target.value];if(v){$('#camera-form').elements.mainPath.value=v[0];$('#camera-form').elements.subPath.value=v[1];}};
|
||||
function resetPlayback() {playbackGeneration++;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();$('#play-empty').hidden=false;$('#download').hidden=true;spans=[];queryCamera='';queryStart=null;queryEnd=null;$('#play-info').textContent='选择日期,查看实际录像时段';drawTimeline();$('#recording-list').innerHTML='';}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>赵府智家 · 视觉</title><link rel="stylesheet" href="/style.css">
|
||||
<script defer src="/vendor/hls.min.js"></script><script defer src="/app.js"></script>
|
||||
<script defer src="/vendor/hls.min.js"></script><script defer src="/live-player.js"></script><script defer src="/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
(function (root) {
|
||||
class LivePlayer {
|
||||
static key(cameras, quality) {
|
||||
// Polling readiness, labels and recording status must not recreate video.
|
||||
return JSON.stringify(cameras.map(c => [c.id, !!c.enabled])) + quality;
|
||||
}
|
||||
constructor(video, panel, source, options = {}) {
|
||||
this.video = video; this.panel = panel; this.source = source;
|
||||
this.Hls = options.Hls || root.Hls;
|
||||
this.clock = options.clock || root;
|
||||
this.now = options.now || (() => Date.now());
|
||||
this.hls = null; this.timer = null; this.stopped = false;
|
||||
this.attempt = 0; this.played = false; this.userPaused = false;
|
||||
this.connecting = true; this.failed = false;
|
||||
this.lastTime = 0; this.lastProgress = this.now(); this.stableSince = 0;
|
||||
this.handlers = {
|
||||
playing: () => {
|
||||
if (this.timer !== null) this.clock.clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.played = true; this.connecting = false; this.failed = false;
|
||||
this.userPaused = false; this.lastProgress = this.now();
|
||||
this.lastTime = video.currentTime; this.stableSince = this.now();
|
||||
panel.querySelector('.video-empty').hidden = true;
|
||||
panel.querySelector('.video-error').hidden = true;
|
||||
},
|
||||
pause: () => { if (!this.connecting && this.played && !video.ended && !video.error && video.readyState >= 2) this.userPaused = true; },
|
||||
play: () => { this.userPaused = false; this.lastProgress = this.now(); if (this.failed) this.retryIfNeeded(); },
|
||||
error: () => this.retry('画面暂时中断'),
|
||||
ended: () => this.retry('视频连接已结束')
|
||||
};
|
||||
for (const [event, handler] of Object.entries(this.handlers)) video.addEventListener(event, handler);
|
||||
this.watchdog = this.clock.setInterval(() => this.watch(), 5000);
|
||||
this.start();
|
||||
}
|
||||
message(text) {
|
||||
const el = this.panel.querySelector('.video-error'); el.textContent = text; el.hidden = false;
|
||||
}
|
||||
start() {
|
||||
if (this.stopped) return;
|
||||
this.connecting = true; this.failed = false;
|
||||
this.release();
|
||||
this.lastProgress = this.now(); this.lastTime = 0;
|
||||
const Hls = this.Hls;
|
||||
if (Hls?.isSupported()) {
|
||||
const hls = new Hls({
|
||||
maxBufferLength: 20, backBufferLength: 5,
|
||||
liveSyncDurationCount: 3, liveMaxLatencyDurationCount: 8,
|
||||
maxLiveSyncPlaybackRate: 1.2,
|
||||
manifestLoadingTimeOut: 60000, levelLoadingTimeOut: 15000,
|
||||
fragLoadingTimeOut: 15000, manifestLoadingMaxRetry: 2, levelLoadingMaxRetry: 2
|
||||
});
|
||||
this.hls = hls;
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (this.hls === hls && data.fatal) this.retry('画面暂时中断');
|
||||
});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (this.hls === hls && !this.stopped) this.video.play().catch(() => {});
|
||||
});
|
||||
hls.loadSource(this.source); hls.attachMedia(this.video);
|
||||
} else if (this.video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
this.video.src = this.source; this.video.play().catch(() => {});
|
||||
} else {
|
||||
this.userPaused = true;
|
||||
this.message('当前浏览器不支持视频播放,请使用较新的浏览器。');
|
||||
}
|
||||
}
|
||||
retry(reason) {
|
||||
if (this.stopped || this.timer !== null) return;
|
||||
this.failed = true;
|
||||
if (this.userPaused) return;
|
||||
const delay = Math.min(30000, 2000 * 2 ** Math.min(this.attempt++, 4));
|
||||
this.message(reason + ',正在自动重新连接…');
|
||||
this.timer = this.clock.setTimeout(() => {
|
||||
this.timer = null;
|
||||
if (!this.stopped) this.start();
|
||||
}, delay);
|
||||
}
|
||||
watch() {
|
||||
if (this.stopped || this.timer !== null || this.userPaused) return;
|
||||
const current = this.video.currentTime;
|
||||
if (this.video.readyState >= 2 && this.video.videoWidth > 0 && current > this.lastTime + 0.1) {
|
||||
this.lastTime = current; this.lastProgress = this.now();
|
||||
if (this.stableSince && this.now() - this.stableSince > 30000) this.attempt = 0;
|
||||
return;
|
||||
}
|
||||
const grace = this.connecting ? 65000 : 25000;
|
||||
if (this.now() - this.lastProgress > grace) this.retry('画面停顿');
|
||||
}
|
||||
retryIfNeeded() {
|
||||
if (!this.failed || this.stopped) return;
|
||||
if (this.timer !== null) this.clock.clearTimeout(this.timer);
|
||||
this.timer = null; this.start();
|
||||
}
|
||||
release() {
|
||||
const old = this.hls; this.hls = null;
|
||||
if (old) old.destroy();
|
||||
this.video.pause(); this.video.removeAttribute('src'); this.video.load();
|
||||
}
|
||||
destroy() {
|
||||
this.stopped = true; this.connecting = true;
|
||||
if (this.timer !== null) this.clock.clearTimeout(this.timer);
|
||||
this.timer = null; this.clock.clearInterval(this.watchdog);
|
||||
for (const [event, handler] of Object.entries(this.handlers)) this.video.removeEventListener(event, handler);
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = LivePlayer;
|
||||
else root.ZhaoLivePlayer = LivePlayer;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
Reference in New Issue
Block a user