Compare commits

..

3 Commits

9 changed files with 266 additions and 34 deletions
+1 -1
View File
@@ -27,7 +27,7 @@
当前 RTSP 负责实时视频,ONVIF 负责读取设备身份、媒体配置、独立视频源及主子码流地址。不同品牌使用相同对象结构,路径和能力由设备返回值或对应驱动确定;不按品牌名字保证所有型号兼容。当前品牌驱动只提供路径模板,自动摄像头配置读取支持 ONVIF Media v1。 当前 RTSP 负责实时视频,ONVIF 负责读取设备身份、媒体配置、独立视频源及主子码流地址。不同品牌使用相同对象结构,路径和能力由设备返回值或对应驱动确定;不按品牌名字保证所有型号兼容。当前品牌驱动只提供路径模板,自动摄像头配置读取支持 ONVIF Media v1。
主码流用于清晰预览和可选本机录像。默认多画面预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始主、子码流仍可选择。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。 主码流用于清晰预览和可选本机录像。界面只显示主码流与子码流两个选项,默认主码流。子码流预览由每路子码流经 FFmpeg 转为 H.264 / AAC;MediaMTX 的 runOnDemand 仅在观看时启动受限分辨率的转码进程,发布权限限定为回环地址的兼容预览路径。高清兼容预览读取原始主码流,经 Intel VAAPI 完成解码、1080p 缩放和 H.264 编码,最高 15 帧/秒;无可用硬件时明确失败,不自动启动多路软件高清转换。原始编码直出不作为网页预览选项,原始主码流继续用于录像及转码输入。系统目前不修改摄像头或录像机设置,不自动导入不存在的通道,不把服务可达当成画面接通。
## 管理 API ## 管理 API
+5 -1
View File
@@ -18,7 +18,9 @@
本机录像默认关闭,用户逐通道开启。默认保留 7 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。文件路径及录像索引使用 UTC,界面按浏览器时区显示。 本机录像默认关闭,用户逐通道开启。默认保留 7 天、容量 40 GB、磁盘预留 8 GB,界面可调整。保留期限到达后 MediaMTX 自动删除本机录像;配额达到后暂停录制,不删除录像机中的数据。配额每 20 秒观察一次,允许少量超出。文件路径及录像索引使用 UTC,界面按浏览器时区显示。
HLS 会有数秒延迟。网页默认使用 FFmpeg 按需生成的 H.264 Baseline / AAC 兼容画面,最高 768×432、10 帧/秒,供小电脑上的多镜头预览。“高清 · 主码流兼容”使用实际主码流,通过 Intel VAAPI 硬件解码、缩放及 H.264 Main 编码,最高 1920×1080、15 帧/秒。高清预览需要可用的 Intel 核显、驱动及 render 设备权限,不自动退回高负载的软件高清转码。首开需要等待取流与缓冲。没有观看者后会停止转码;同一镜头的观看者共享一路转码。用户仍可选择原始主、子码流,但 H.265 的兼容性取决于浏览器。本机录像保留原始主码流编码,兼容预览不改变录像及摄像头设置。网页回放按需转换为 H.264/AAC,最多同时两个会话,每段最多一小时;使用时间轴或定位时间跳转。关闭回放或切换页面后终止转换,不生成额外缓存文件。有可用 VAAPI 时回放最高 1080p/15 帧,否则使用 768×432/10 帧软件转换。下载保留原始录像编码,需支持该编码的播放器。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。 HLS 会有数秒延迟。网页清晰度仅提供“主码流”和“子码流”两个选项,默认主码流。主码流预览使用实际主码流,通过 Intel VAAPI 硬件解码、缩放及 H.264 Main 编码,最高 1920×1080、15 帧/秒。子码流预览使用实际子码流,由 FFmpeg 按需生成 H.264 Baseline / AAC 兼容画面,最高 768×432、10 帧/秒。高清预览需要可用的 Intel 核显、驱动及 render 设备权限,不自动退回高负载的软件高清转码。首开需要等待取流与缓冲。没有观看者后会停止转码;同一镜头的观看者共享一路转码。网页不提供原始编码直出的预览选项。本机录像保留原始主码流编码,兼容预览不改变录像及摄像头设置。网页回放按需转换为 H.264/AAC,最多同时两个会话,每段最多一小时;使用时间轴或定位时间跳转。关闭回放或切换页面后终止转换,不生成额外缓存文件。有可用 VAAPI 时回放最高 1080p/15 帧,否则使用 768×432/10 帧软件转换。下载保留原始录像编码,需支持该编码的播放器。设备状态基于 MediaMTX 实际码流状态;“正在录像”还要求观察到近期写入的本机录像文件。离线不会生成假画面或假录像。
实时预览按镜头独立恢复连接:普通状态轮询只更新标签,不重建播放器;断流后按 2–30 秒退避重连,画面长时间不前进时也会自动恢复。用户主动暂停会保留暂停状态,离开页面会清理播放器和重试任务。
## Ubuntu x86_64 部署 ## Ubuntu x86_64 部署
@@ -47,6 +49,8 @@ python3 app.py
mediamtx ./data/mediamtx.yml mediamtx ./data/mediamtx.yml
``` ```
回归测试:`python3 -m unittest -v test_app` 和 `node --test test_live_player.js`。测试使用临时数据库和模拟播放器,不连接真实设备。
访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`;高清转码可用 `VISION_VAAPI_DEVICE` 指定渲染设备(默认 `/dev/dri/renderD128`)。HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 是单管理员模型。 访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`;高清转码可用 `VISION_VAAPI_DEVICE` 指定渲染设备(默认 `/dev/dri/renderD128`)。HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 是单管理员模型。
## 数据与恢复 ## 数据与恢复
+1 -1
View File
@@ -1 +1 @@
0.1.7 0.1.10
+13 -4
View File
@@ -310,6 +310,15 @@ def media_json(path, playback=False):
req = urllib.request.Request(target, headers={'Authorization': MEDIA_AUTH}) req = urllib.request.Request(target, headers={'Authorization': MEDIA_AUTH})
with urllib.request.urlopen(req, timeout=5) as r: with urllib.request.urlopen(req, timeout=5) as r:
return json.load(r) return json.load(r)
except urllib.error.HTTPError as exc:
if playback and path.startswith('/list?') and exc.code == 404:
try:
detail = json.loads(exc.read(4096))
if detail.get('error') == 'no recording segments found':
return []
except (ValueError, AttributeError):
pass
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503)
except Exception: except Exception:
raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503) raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503)
@@ -402,9 +411,9 @@ def storage_watch():
def discover(network): def discover(network):
def probe(ip): def probe(ip):
try: try:
with socket.create_connection((str(ip), 554), timeout=0.7) as s: with socket.create_connection((str(ip), 554), timeout=1.5) as s:
s.settimeout(1) s.settimeout(3)
s.sendall(('OPTIONS rtsp://' + str(ip) + '/ RTSP/1.0\r\nCSeq: 1\r\n\r\n').encode()) s.sendall(('OPTIONS rtsp://' + str(ip) + ':554/ RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: ZhaoVision\r\n\r\n').encode())
response = s.recv(1024).decode(errors='replace').split('\r\n')[0] response = s.recv(1024).decode(errors='replace').split('\r\n')[0]
if response.startswith('RTSP/'): if response.startswith('RTSP/'):
return {'host': str(ip), 'port': 554, 'response': response, 'authenticated': False} return {'host': str(ip), 'port': 554, 'response': response, 'authenticated': False}
@@ -725,7 +734,7 @@ class Handler(BaseHTTPRequestHandler):
else: else:
raise Problem('接口不存在', 404) raise Problem('接口不存在', 404)
else: else:
files = {'/': 'index.html', '/app.js': 'app.js', '/style.css': 'style.css', files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css',
'/vendor/hls.min.js': 'vendor/hls.min.js'} '/vendor/hls.min.js': 'vendor/hls.min.js'}
if path not in files: if path not in files:
raise Problem('页面不存在', 404) raise Problem('页面不存在', 404)
+21
View File
@@ -1,8 +1,10 @@
"""Isolated regression tests: python -m unittest -v test_app.""" """Isolated regression tests: python -m unittest -v test_app."""
import hashlib import hashlib
import http.client import http.client
import io
import json import json
import os import os
import re
import tempfile import tempfile
import threading import threading
import time import time
@@ -62,6 +64,13 @@ class WebTests(unittest.TestCase):
self.assertEqual(headers['X-Frame-Options'], 'DENY') self.assertEqual(headers['X-Frame-Options'], 'DENY')
self.assertIn("frame-ancestors 'none'", headers['Content-Security-Policy']) self.assertIn("frame-ancestors 'none'", headers['Content-Security-Policy'])
def test_live_quality_has_only_main_and_sub(self):
code, _, body = self.request('/', False)
self.assertEqual(code, 200)
select = re.search(r'<select id="quality">(.*?)</select>', body.decode()).group(1)
self.assertEqual(re.findall(r'<option value="(.*?)">(.*?)</option>', select),
[('hd', '主码流'), ('compat', '子码流')])
def test_origin_rejected(self): def test_origin_rejected(self):
self.assertEqual(self.request('/api/storage', data={}, origin='https://example.org')[0], 403) self.assertEqual(self.request('/api/storage', data={}, origin='https://example.org')[0], 403)
@@ -128,6 +137,18 @@ class WebTests(unittest.TestCase):
self.assertIn('h264_vaapi', command) self.assertIn('h264_vaapi', command)
self.assertIn('pipe:1', command) self.assertIn('pipe:1', command)
def test_empty_recording_day_is_not_service_failure(self):
error = app.urllib.error.HTTPError('http://localhost/list', 404, 'Not Found', {},
io.BytesIO(b'{"error":"no recording segments found"}'))
with patch('app.urllib.request.urlopen', side_effect=error):
self.assertEqual(app.media_json('/list?path=example', playback=True), [])
def test_real_media_service_error_is_preserved(self):
error = app.urllib.error.HTTPError('http://localhost/list', 500, 'Error', {}, io.BytesIO(b'{}'))
with patch('app.urllib.request.urlopen', side_effect=error):
with self.assertRaises(app.Problem):
app.media_json('/list?path=example', playback=True)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+91
View File
@@ -0,0 +1,91 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const LivePlayer = require('./web/live-player.js');
function fixture() {
let now = 100, serial = 0;
const timeouts = new Map(), intervals = new Map();
const clock = {
setTimeout(fn, ms) {const id=++serial;timeouts.set(id,{fn,ms});return id;},
clearTimeout(id) {timeouts.delete(id);},
setInterval(fn, ms) {const id=++serial;intervals.set(id,{fn,ms});return id;},
clearInterval(id) {intervals.delete(id);}
};
const listeners = new Map(), indicators = {'.video-empty':{hidden:false},'.video-error':{hidden:true}};
const video = {
currentTime:0,readyState:0,videoWidth:0,paused:false,ended:false,error:null,
addEventListener(e,f) {listeners.set(e,f);},removeEventListener(e) {listeners.delete(e);},
emit(e) {listeners.get(e)?.();},
play() {this.paused=false;this.emit('play');return Promise.resolve();},
pause() {this.paused=true;this.emit('pause');},removeAttribute() {},load() {},
canPlayType() {return '';}
};
const instances=[];
class Hls {
static Events={ERROR:'error',MANIFEST_PARSED:'manifest'};
static isSupported() {return true;}
constructor() {this.events={};instances.push(this);}
on(e,f) {this.events[e]=f;}
loadSource() {} attachMedia() {} destroy() {this.destroyed=true;}
fail() {this.events.error(null,{fatal:true});}
}
const player=new LivePlayer(video,{querySelector:s=>indicators[s]},'/example',{Hls,clock,now:()=>now});
return {player,video,instances,timeouts,intervals,indicators,
advance(ms) {now+=ms;player.watch();},
playing() {video.readyState=4;video.videoWidth=1920;video.emit('playing');},
retry() {const [id,t]=timeouts.entries().next().value;timeouts.delete(id);now+=t.ms;t.fn();}
};
}
test('readiness, recording status and labels do not change playback identity',()=>{
const camera={id:'one',enabled:true,ready:true,name:'A',recordingActive:false};
const original=LivePlayer.key([camera],'hd');
assert.equal(LivePlayer.key([{...camera,ready:false,name:'B',recordingActive:true}],'hd'),original);
assert.notEqual(LivePlayer.key([camera],'compat'),original);
});
test('fatal error reconnects only its own player with bounded backoff',()=>{
const a=fixture(),b=fixture();
for(let i=0;i<8;i++) {
a.instances.at(-1).fail();
assert.equal(a.timeouts.size,1);
assert.ok([...a.timeouts.values()][0].ms<=30000);
a.retry();
}
assert.equal(b.instances.length,1);
a.player.destroy();b.player.destroy();
});
test('duplicate errors schedule only one retry',()=>{
const f=fixture();f.instances[0].fail();f.video.emit('error');
assert.equal(f.timeouts.size,1);f.player.destroy();
});
test('continuous playback does not restart during repeated health checks',()=>{
const f=fixture();f.playing();
for(let i=0;i<72;i++) {f.video.currentTime+=5;f.advance(5000);}
assert.equal(f.instances.length,1);assert.equal(f.timeouts.size,0);f.player.destroy();
});
test('stalled playback automatically reconnects',()=>{
const f=fixture();f.playing();f.advance(30000);
assert.equal(f.timeouts.size,1);f.retry();assert.equal(f.instances.length,2);f.player.destroy();
});
test('startup has enough time to wait for a source',()=>{
const f=fixture();f.advance(60000);assert.equal(f.timeouts.size,0);
f.advance(6000);assert.equal(f.timeouts.size,1);f.player.destroy();
});
test('intentional pause is preserved and resume can recover a failed connection',()=>{
const f=fixture();f.playing();f.video.pause();f.instances[0].fail();f.advance(90000);
assert.equal(f.instances.length,1);assert.equal(f.timeouts.size,0);
f.video.play();assert.equal(f.instances.length,2);f.player.destroy();
});
test('destroy cancels timers and releases the old player',()=>{
const f=fixture();f.instances[0].fail();f.player.destroy();
assert.equal(f.timeouts.size,0);assert.equal(f.intervals.size,0);assert.equal(f.instances[0].destroyed,true);
});
test('recovery before timeout cancels pending retry',()=>{
const f=fixture();f.instances[0].fail();f.playing();
assert.equal(f.timeouts.size,0);assert.equal(f.indicators['.video-error'].hidden,true);f.player.destroy();
});
test('error from an old HLS instance cannot restart its replacement',()=>{
const f=fixture();const old=f.instances[0];old.fail();f.retry();old.fail();
assert.equal(f.timeouts.size,0);assert.equal(f.instances.length,2);f.player.destroy();
});
+21 -24
View File
@@ -4,7 +4,7 @@ const $$ = (s) => [...document.querySelectorAll(s)];
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他'); const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他');
let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false; let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false;
let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer; let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer, playbackGeneration = 0;
const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']}; const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']};
async function api(path, data) { async function api(path, data) {
const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:data === undefined ? {} : {'Content-Type':'application/json'}, body:data === undefined ? undefined : JSON.stringify(data)}); const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:data === undefined ? {} : {'Content-Type':'application/json'}, body:data === undefined ? undefined : JSON.stringify(data)});
@@ -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 empty(title, description, button) { return `<div class="empty"><h3>${esc(title)}</h3><p>${esc(description)}</p>${button || ''}</div>`; }
function renderLive() { function renderLive() {
const cameras = filtered(), quality = $('#quality').value; 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; const key = ZhaoLivePlayer.key(cameras, quality);
if (key === liveKey) return; if (key === liveKey) { updateLiveStatus(cameras); return; }
stopPlayers(); liveKey = key; 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>'); $('#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)) { for (const c of cameras.filter(c=>c.enabled)) {
const video = $(`video[data-camera="${c.id}"]`), panel = video.parentElement; const video = $(`video[data-camera="${c.id}"]`);
const src = `/media/live/cam_${c.id}_${quality}/index.m3u8`; players.push(new ZhaoLivePlayer(video, video.parentElement, `/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 播放,请使用较新的浏览器。');
} }
} }
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() { function renderCameras() {
$('#scan-button').disabled=state.scan.running; $('#scan-button').textContent=state.scan.running?'正在探测…':'开始探测'; $('#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>' : ''); $('#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,16 +103,16 @@ $('#auth-form').addEventListener('submit',async e=>{e.preventDefault();const f=e
$('#auth-dialog').addEventListener('cancel',e=>e.preventDefault()); $('#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));}); 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();}; $('#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);}}; $('#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];}}; $('#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() {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='';} 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='';}
async function queryRecordings() { async function queryRecordings() {
resetPlayback();const id=$('#play-camera').value,date=$('#play-date').value;if(!id||!date){toast('请选择摄像头和日期');return;} resetPlayback();const generation=playbackGeneration;const id=$('#play-camera').value,date=$('#play-date').value;if(!id||!date){toast('请选择摄像头和日期');return;}
const channel=state.cameras.find(c=>c.id===id);if($('#archive-source').value==='recorder'){$('#play-info').textContent='录像机历史回放接口待接入。请先提供录像机型号,在录像机或原厂 App 中可继续查看已有录像。';return;} const channel=state.cameras.find(c=>c.id===id);if($('#archive-source').value==='recorder'){$('#play-info').textContent='录像机历史回放接口待接入。请先提供录像机型号,在录像机或原厂 App 中可继续查看已有录像。';return;}
queryStart=new Date(date+'T00:00:00');queryEnd=new Date(queryStart);queryEnd.setDate(queryEnd.getDate()+1);queryCamera=id; queryStart=new Date(date+'T00:00:00');queryEnd=new Date(queryStart);queryEnd.setDate(queryEnd.getDate()+1);queryCamera=id;
$('#play-info').textContent='正在查找录像…'; $('#play-info').textContent='正在查找录像…';
try{const d=await api('/api/recordings?'+new URLSearchParams({camera:id,start:queryStart.toISOString(),end:queryEnd.toISOString()}));spans=d.items.map(s=>({...s,date:new Date(s.start)}));$('#play-info').textContent=spans.length?`找到 ${spans.length} 段连续录像`:'该日期暂无可回放录像';drawTimeline();$('#recording-list').innerHTML=spans.map(s=>`<button class="recording-item" data-span="${esc(s.start)}">${s.date.toLocaleTimeString()} — ${new Date(+s.date+s.duration*1000).toLocaleTimeString()}<small>${(s.duration/60).toFixed(1)} 分钟 · 点击播放</small></button>`).join('');if(spans.length)playAt(new Date(Math.max(+spans[0].date,+queryStart)));}catch(e){$('#play-info').textContent=e.message;} try{const d=await api('/api/recordings?'+new URLSearchParams({camera:id,start:queryStart.toISOString(),end:queryEnd.toISOString()}));if(generation!==playbackGeneration)return;spans=d.items.map(s=>({...s,date:new Date(s.start)}));$('#play-info').textContent=spans.length?`找到 ${spans.length} 段连续录像`:'该日期暂无可回放录像';drawTimeline();$('#recording-list').innerHTML=spans.map(s=>`<button class="recording-item" data-span="${esc(s.start)}">${s.date.toLocaleTimeString()} — ${new Date(+s.date+s.duration*1000).toLocaleTimeString()}<small>${(s.duration/60).toFixed(1)} 分钟 · 点击播放</small></button>`).join('');if(spans.length)playAt(new Date(Math.max(+spans[0].date,+queryStart)));}catch(e){if(generation===playbackGeneration)$('#play-info').textContent=e.message;}
} }
function drawTimeline() {const c=$('#timeline'),w=c.clientWidth;if(!w)return;const scale=window.devicePixelRatio||1;c.width=w*scale;c.height=76*scale;const ctx=c.getContext('2d');ctx.scale(scale,scale);ctx.fillStyle='#101f25';ctx.fillRect(0,0,w,76);ctx.strokeStyle='#2b454e';for(let i=0;i<=24;i++){const x=w*i/24;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,i%6===0?76:12);ctx.stroke();}if(!queryStart||!queryEnd)return;ctx.fillStyle='#72cdb5';const total=queryEnd-queryStart;for(const s of spans){const a=Math.max(0,(s.date-queryStart)/total),b=Math.min(1,(+s.date+s.duration*1000-queryStart)/total);if(b>a)ctx.fillRect(a*w,25,Math.max(2,(b-a)*w),25);}} function drawTimeline() {const c=$('#timeline'),w=c.clientWidth;if(!w)return;const scale=window.devicePixelRatio||1;c.width=w*scale;c.height=76*scale;const ctx=c.getContext('2d');ctx.scale(scale,scale);ctx.fillStyle='#101f25';ctx.fillRect(0,0,w,76);ctx.strokeStyle='#2b454e';for(let i=0;i<=24;i++){const x=w*i/24;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,i%6===0?76:12);ctx.stroke();}if(!queryStart||!queryEnd)return;ctx.fillStyle='#72cdb5';const total=queryEnd-queryStart;for(const s of spans){const a=Math.max(0,(s.date-queryStart)/total),b=Math.min(1,(+s.date+s.duration*1000-queryStart)/total);if(b>a)ctx.fillRect(a*w,25,Math.max(2,(b-a)*w),25);}}
function playAt(date) {const s=spans.find(s=>+date>=+s.date&&+date<+s.date+s.duration*1000);if(!s){toast('这个时间没有录像,请选择绿色时段');return;}const duration=Math.min(3600,(+s.date+s.duration*1000-date)/1000);const params=new URLSearchParams({camera:queryCamera,start:date.toISOString(),duration:String(duration)});$('#play-video').src='/media/playback?'+params;$('#play-video').play().catch(()=>{});$('#play-info').textContent='回放起点 '+date.toLocaleString()+' · 兼容回放,最多一小时;使用下方时间轴定位';$('#play-empty').hidden=true;$('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false;} function playAt(date) {const s=spans.find(s=>+date>=+s.date&&+date<+s.date+s.duration*1000);if(!s){toast('这个时间没有录像,请选择绿色时段');return;}const duration=Math.min(3600,(+s.date+s.duration*1000-date)/1000);const params=new URLSearchParams({camera:queryCamera,start:date.toISOString(),duration:String(duration)});$('#play-video').src='/media/playback?'+params;$('#play-video').play().catch(()=>{});$('#play-info').textContent='回放起点 '+date.toLocaleString()+' · 兼容回放,最多一小时;使用下方时间轴定位';$('#play-empty').hidden=true;$('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false;}
+3 -3
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>赵府智家 · 视觉</title><link rel="stylesheet" href="/style.css"> <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> </head>
<body> <body>
<aside class="sidebar"> <aside class="sidebar">
@@ -24,7 +24,7 @@
<div id="banner" class="banner" hidden></div> <div id="banner" class="banner" hidden></div>
<section class="metrics"><div><span>摄像头</span><strong id="metric-all">—</strong></div><div><span>已接通码流</span><strong id="metric-ready">—</strong></div><div><span>录像占用</span><strong id="metric-used">—</strong></div><div><span>空间档案</span><strong id="metric-sites">—</strong></div></section> <section class="metrics"><div><span>摄像头</span><strong id="metric-all">—</strong></div><div><span>已接通码流</span><strong id="metric-ready">—</strong></div><div><span>录像占用</span><strong id="metric-used">—</strong></div><div><span>空间档案</span><strong id="metric-sites">—</strong></div></section>
<div class="filter-row"><label>空间范围 <select id="site-filter"><option value="">全部空间</option></select></label><span id="filter-caption">家庭与公司,分别管理</span><button id="refresh" class="quiet">↻ 刷新状态</button></div> <div class="filter-row"><label>空间范围 <select id="site-filter"><option value="">全部空间</option></select></label><span id="filter-caption">家庭与公司,分别管理</span><button id="refresh" class="quiet">↻ 刷新状态</button></div>
<section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>LIVE VIEW</small></h2><div><label>清晰度 <select id="quality"><option value="compat">流畅 · 兼容画面</option><option value="hd">高清 · 主码流兼容</option><option value="sub">原始 · 子码流</option><option value="main">原始 · 主码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">流畅画面最高 768×432;高清画面从主码流转换,最高 1920×1080、15 帧/秒。原始画面需要浏览器支持摄像头编码。</p><div id="live-grid" class="live-grid"></div></section> <section id="page-live" class="page"><div class="section-head"><h2>现场画面 <small>LIVE VIEW</small></h2><div><label>清晰度 <select id="quality"><option value="hd">主码流</option><option value="compat">子码流</option></select></label><button data-action="new-camera">+ 添加镜头通道</button></div></div><p class="description" id="quality-note">主码流画面更清晰,最高 1920×1080;子码流更流畅,最高 768×432,适合网络较慢时使用。</p><div id="live-grid" class="live-grid"></div></section>
<section id="page-playback" class="page" hidden> <section id="page-playback" class="page" hidden>
<div class="section-head"><h2>回到那一刻 <small>PLAYBACK</small></h2></div> <div class="section-head"><h2>回到那一刻 <small>PLAYBACK</small></h2></div>
<div class="panel playback-controls"><label>摄像头<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div> <div class="panel playback-controls"><label>摄像头<select id="play-camera"></select></label><label>录像来源<select id="archive-source"><option value="recorder">录像机原有录像</option><option value="local">小电脑本机录像</option></select></label><label>日期<input id="play-date" type="date"></label><button id="query-recordings">查找录像</button><span id="play-info">选择日期,查看实际录像时段</span></div>
@@ -36,7 +36,7 @@
<section id="page-objects" class="page" hidden><div class="section-head"><h2>摄像头对象 <small>CAMERA ASSETS</small></h2><button data-action="new-asset">+ 新建摄像头对象</button></div><p class="description">一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。</p><div id="asset-list" class="site-grid"></div></section> <section id="page-objects" class="page" hidden><div class="section-head"><h2>摄像头对象 <small>CAMERA ASSETS</small></h2><button data-action="new-asset">+ 新建摄像头对象</button></div><p class="description">一台实体摄像头对应一个对象,双镜头在对象下建立两个镜头通道;每个镜头分别有主码流和子码流。</p><div id="asset-list" class="site-grid"></div></section>
<section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>RECORDERS</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section> <section id="page-recorders" class="page" hidden><div class="section-head"><h2>录像机 <small>RECORDERS</small></h2><button data-action="new-recorder">+ 添加录像机</button></div><p class="description">统一保存录像机地址与账号,镜头通道关联录像机。实时取流使用 RTSP;读取录像机已有历史录像,需要该型号的回放接口适配。</p><div id="recorder-list" class="site-grid"></div></section>
<section id="page-sites" class="page" hidden><div class="section-head"><h2>空间档案 <small>PLACES</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">为家庭、公司或其他场所建立地址档案,再关联摄像头。地区、小区、楼栋和门牌都是独立属性。</p><div id="site-list" class="site-grid"></div></section> <section id="page-sites" class="page" hidden><div class="section-head"><h2>空间档案 <small>PLACES</small></h2><button data-action="new-site">+ 新建空间</button></div><p class="description">为家庭、公司或其他场所建立地址档案,再关联摄像头。地区、小区、楼栋和门牌都是独立属性。</p><div id="site-list" class="site-grid"></div></section>
<section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>STORAGE</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用 H.264 流畅画面(最高 768×432、10 帧/秒),也可选择从主码流转换的 1080p 高清画面。原始码流与本机录像保留设备编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section> <section id="page-storage" class="page" hidden><div class="section-head"><h2>录像与存储 <small>STORAGE</small></h2></div><div class="storage-grid"><form id="storage-form" class="panel"><h3>录像保留策略</h3><label>保留天数<input name="retentionDays" type="number" min="1" max="365" required></label><label>录像容量上限(GB)<input name="maxGB" type="number" min="5" max="100000" required></label><label>磁盘预留空间(GB)<input name="reserveGB" type="number" min="2" max="1000" required></label><p class="description">超过保留天数的录像会自动删除。达到容量上限或预留空间不足时暂停录像;调整策略后自动恢复。容量每 20 秒检查一次,可能有少量超出。</p><button type="submit">保存策略</button><span class="form-error"></span></form><div class="panel"><h3>运行状态</h3><dl id="storage-status"></dl><p class="description">开启摄像头的“持续录像”后,服务会保存主码流。预览默认使用主码流高清画面(最高 1920×1080、15 帧/秒),也可切换子码流流畅画面(最高 768×432、10 帧/秒)。本机录像保留设备原始编码。</p><h3>最近操作</h3><div id="audit-list"></div></div></div></section>
<footer><span>赵府智家 · 视觉系统</span><span id="version">独立部署 / 数据自有</span></footer> <footer><span>赵府智家 · 视觉系统</span><span id="version">独立部署 / 数据自有</span></footer>
</main> </main>
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">WELCOME HOME</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">输入管理密码,进入视觉工作空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>管理员密码<input name="password" type="password" minlength="10" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog> <dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">WELCOME HOME</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">输入管理密码,进入视觉工作空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>管理员密码<input name="password" type="password" minlength="10" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
+110
View File
@@ -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);