iobroker.agent-dvr 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +289 -175
- package/README.md +226 -90
- package/admin/assets/index-ulvdM37k.js +685 -0
- package/admin/i18n/de.json +3 -7
- package/admin/i18n/en.json +131 -135
- package/admin/i18n/es.json +3 -7
- package/admin/i18n/fr.json +3 -7
- package/admin/i18n/it.json +3 -7
- package/admin/i18n/nl.json +3 -7
- package/admin/i18n/pl.json +3 -7
- package/admin/i18n/pt.json +3 -7
- package/admin/i18n/ru.json +3 -7
- package/admin/i18n/uk.json +3 -7
- package/admin/i18n/zh-cn.json +3 -7
- package/admin/index_m.html +27 -0
- package/build/main.js +152 -103
- package/build/main.js.map +2 -2
- package/io-package.json +16 -17
- package/lib/web.js +52 -5
- package/package.json +1 -1
- package/www/flv.min.js +7534 -0
- package/www/index.html +382 -99
- package/admin/custom/go2rtcMapping.js +0 -169
- package/admin/jsonConfig.json +0 -822
package/www/index.html
CHANGED
|
@@ -37,17 +37,18 @@ main{flex:1;overflow:hidden;min-height:0;position:relative}
|
|
|
37
37
|
|
|
38
38
|
/* ── Live view ── */
|
|
39
39
|
#view-live{padding:var(--gap);gap:var(--gap);background:var(--bg)}
|
|
40
|
-
#chip-bar{flex-shrink:0;display:flex;gap:5px;flex-wrap:
|
|
40
|
+
#chip-bar{flex-shrink:0;display:flex;gap:5px;flex-wrap:nowrap;align-items:center;overflow-x:auto;scrollbar-width:none}
|
|
41
|
+
#chip-bar::-webkit-scrollbar{display:none}
|
|
41
42
|
.chip{background:var(--s1);border:1px solid var(--border);border-radius:99px;
|
|
42
43
|
padding:4px 12px;font-size:12px;cursor:pointer;user-select:none;color:var(--muted);
|
|
43
44
|
transition:all .15s;white-space:nowrap}
|
|
44
45
|
.chip:hover{border-color:var(--border2);color:var(--text)}
|
|
45
46
|
.chip.on{background:rgba(37,99,235,.15);border-color:var(--accent);color:var(--accent-l)}
|
|
46
|
-
|
|
47
47
|
#cam-grid{flex:1;min-height:0;display:grid;gap:var(--gap)}
|
|
48
48
|
.cam-tile{background:#000;border-radius:var(--radius);overflow:hidden;position:relative;
|
|
49
49
|
cursor:pointer;min-width:0;min-height:0;border:2px solid transparent;
|
|
50
|
-
transition:border-color .2s,box-shadow .2s
|
|
50
|
+
transition:border-color .2s,box-shadow .2s;
|
|
51
|
+
display:flex;align-items:center;justify-content:center}
|
|
51
52
|
.cam-tile:hover{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent),0 0 16px var(--accent-glow)}
|
|
52
53
|
.cam-tile.detected{border-color:var(--yellow);box-shadow:0 0 0 1px var(--yellow),0 0 14px rgba(234,179,8,.2)}
|
|
53
54
|
.cam-tile.alerted{border-color:var(--orange);box-shadow:0 0 0 1px var(--orange),0 0 14px rgba(249,115,22,.2)}
|
|
@@ -72,6 +73,8 @@ main{flex:1;overflow:hidden;min-height:0;position:relative}
|
|
|
72
73
|
.tile-btn:hover,.ptz-btn.active{background:rgba(37,99,235,.85);border-color:var(--accent);opacity:1;box-shadow:0 0 0 1px rgba(0,0,0,.55),0 2px 8px rgba(0,0,0,.7),0 0 10px var(--accent-glow)}
|
|
73
74
|
.rec-btn.active{background:rgba(239,68,68,.85)!important;border-color:var(--red)!important;opacity:1!important;box-shadow:0 0 0 1px rgba(0,0,0,.55),0 2px 8px rgba(0,0,0,.7),0 0 10px rgba(239,68,68,.4)!important}
|
|
74
75
|
.rec-btn:hover{background:rgba(239,68,68,.7)!important;border-color:var(--red)!important}
|
|
76
|
+
.mute-btn.active{background:rgba(34,197,94,.25)!important;border-color:var(--green)!important;opacity:1!important;box-shadow:0 0 0 1px rgba(0,0,0,.55),0 2px 8px rgba(0,0,0,.7),0 0 10px rgba(34,197,94,.35)!important}
|
|
77
|
+
.mute-btn:hover{background:rgba(34,197,94,.4)!important;border-color:var(--green)!important}
|
|
75
78
|
#fs-panel.detected::after{content:'';position:fixed;inset:0;pointer-events:none;box-shadow:inset 0 0 0 3px var(--yellow);z-index:1}
|
|
76
79
|
#fs-panel.alerted::after{content:'';position:fixed;inset:0;pointer-events:none;box-shadow:inset 0 0 0 3px var(--orange);z-index:1}
|
|
77
80
|
#fs-panel.recording::after{content:'';position:fixed;inset:0;pointer-events:none;box-shadow:inset 0 0 0 3px var(--red);z-index:1}
|
|
@@ -238,10 +241,20 @@ main{flex:1;overflow:hidden;min-height:0;position:relative}
|
|
|
238
241
|
::-webkit-scrollbar-track{background:transparent}
|
|
239
242
|
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
|
|
240
243
|
|
|
244
|
+
|
|
241
245
|
/* ── WebRTC video in tiles ── */
|
|
242
246
|
.cam-tile video{width:100%;height:100%;object-fit:contain;display:block;background:#000}
|
|
243
247
|
|
|
244
248
|
</style>
|
|
249
|
+
<script>
|
|
250
|
+
(function(){
|
|
251
|
+
function load(src){
|
|
252
|
+
return new Promise(function(res,rej){var s=document.createElement('script');s.onload=res;s.onerror=rej;s.src=src;document.head.appendChild(s);});
|
|
253
|
+
}
|
|
254
|
+
window._flvReady = load('flv.min.js')
|
|
255
|
+
.catch(function(){ window._flvFailed = true; });
|
|
256
|
+
})();
|
|
257
|
+
</script>
|
|
245
258
|
</head>
|
|
246
259
|
<body>
|
|
247
260
|
<div id="app">
|
|
@@ -254,7 +267,7 @@ main{flex:1;overflow:hidden;min-height:0;position:relative}
|
|
|
254
267
|
</button>
|
|
255
268
|
<button class="nav-btn" onclick="showView('rec')">
|
|
256
269
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="2" width="14" height="3" rx="1"/><rect x="1" y="7" width="14" height="3" rx="1"/><rect x="1" y="12" width="14" height="3" rx="1"/></svg>
|
|
257
|
-
<span data-i18n="navRec">
|
|
270
|
+
<span data-i18n="navRec">Recordings</span>
|
|
258
271
|
</button>
|
|
259
272
|
</div>
|
|
260
273
|
<div class="hdr-r">
|
|
@@ -273,14 +286,14 @@ main{flex:1;overflow:hidden;min-height:0;position:relative}
|
|
|
273
286
|
<!-- Recordings -->
|
|
274
287
|
<div id="view-rec" class="view">
|
|
275
288
|
<div id="rec-filter-bar">
|
|
276
|
-
<input id="rec-search" type="search" data-i18n-placeholder="searchPlaceholder" placeholder="🔍
|
|
289
|
+
<input id="rec-search" type="search" data-i18n-placeholder="searchPlaceholder" placeholder="🔍 Search…" oninput="filterRec()">
|
|
277
290
|
<select id="rec-cam-sel" onchange="loadAndFilterRec()">
|
|
278
|
-
<option value="" data-i18n="camSelect">
|
|
291
|
+
<option value="" data-i18n="camSelect">Select camera…</option>
|
|
279
292
|
</select>
|
|
280
293
|
<div id="rec-tags"></div>
|
|
281
294
|
<div id="rec-toolbar">
|
|
282
295
|
<span id="rec-count"></span>
|
|
283
|
-
<button id="rec-view-grid" class="rec-view-btn active" data-i18n-title="gridView" title="
|
|
296
|
+
<button id="rec-view-grid" class="rec-view-btn active" data-i18n-title="gridView" title="Grid view" onclick="setRecView('grid')">
|
|
284
297
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
|
285
298
|
<rect x="0" y="0" width="6" height="6" rx="1"/><rect x="8" y="0" width="6" height="6" rx="1"/>
|
|
286
299
|
<rect x="0" y="8" width="6" height="6" rx="1"/><rect x="8" y="8" width="6" height="6" rx="1"/>
|
|
@@ -300,7 +313,7 @@ main{flex:1;overflow:hidden;min-height:0;position:relative}
|
|
|
300
313
|
</div>
|
|
301
314
|
</main>
|
|
302
315
|
|
|
303
|
-
<div id="sbar"><span id="sbar-msg" data-i18n="sbarLoading">
|
|
316
|
+
<div id="sbar"><span id="sbar-msg" data-i18n="sbarLoading">Loading…</span></div>
|
|
304
317
|
</div><!-- #app -->
|
|
305
318
|
|
|
306
319
|
<!-- Video panel (persistent overlay, survives tab switch) -->
|
|
@@ -313,9 +326,9 @@ main{flex:1;overflow:hidden;min-height:0;position:relative}
|
|
|
313
326
|
</div>
|
|
314
327
|
<video id="vid-el" controls playsinline preload="auto"></video>
|
|
315
328
|
<div id="vid-nav">
|
|
316
|
-
<button id="vid-prev" onclick="playRecIdx(recPlayIdx - 1)" data-i18n="vidPrev">‹
|
|
329
|
+
<button id="vid-prev" onclick="playRecIdx(recPlayIdx - 1)" data-i18n="vidPrev">‹ Previous</button>
|
|
317
330
|
<span id="vid-pos"></span>
|
|
318
|
-
<button id="vid-next" onclick="playRecIdx(recPlayIdx + 1)" data-i18n="vidNext">
|
|
331
|
+
<button id="vid-next" onclick="playRecIdx(recPlayIdx + 1)" data-i18n="vidNext">Next ›</button>
|
|
319
332
|
</div>
|
|
320
333
|
<div id="vid-meta">
|
|
321
334
|
<span id="vid-meta-txt"></span>
|
|
@@ -363,6 +376,8 @@ const TRANSLATIONS = {
|
|
|
363
376
|
tagAll: 'Alle',
|
|
364
377
|
recStart: 'Aufnahme starten',
|
|
365
378
|
recStop: 'Aufnahme stoppen',
|
|
379
|
+
audioOn: 'Ton einschalten',
|
|
380
|
+
audioOff: 'Ton ausschalten',
|
|
366
381
|
recLoading: 'Lade…',
|
|
367
382
|
recNone: 'Keine Aufnahmen.<br><small>enableEventDps in Adapter-Config prüfen.</small>',
|
|
368
383
|
recError: 'Fehler: ',
|
|
@@ -370,6 +385,10 @@ const TRANSLATIONS = {
|
|
|
370
385
|
recCount: '{n} Aufnahme(n)',
|
|
371
386
|
zoomOut: 'Rauszoomen',
|
|
372
387
|
zoomIn: 'Reinzoomen',
|
|
388
|
+
flvNoLib: 'ERR: flv.js konnte nicht geladen werden',
|
|
389
|
+
flvLoadingLib: 'lade flv.js…',
|
|
390
|
+
flvNoMse: 'ERR: MSE nicht unterstützt (Browser zu alt?)',
|
|
391
|
+
flvConnecting: 'verbinde…',
|
|
373
392
|
reload: 'Neu laden',
|
|
374
393
|
},
|
|
375
394
|
en: {
|
|
@@ -390,6 +409,8 @@ const TRANSLATIONS = {
|
|
|
390
409
|
tagAll: 'All',
|
|
391
410
|
recStart: 'Start recording',
|
|
392
411
|
recStop: 'Stop recording',
|
|
412
|
+
audioOn: 'Enable audio',
|
|
413
|
+
audioOff: 'Mute audio',
|
|
393
414
|
recLoading: 'Loading…',
|
|
394
415
|
recNone: 'No recordings.<br><small>Check enableEventDps in adapter config.</small>',
|
|
395
416
|
recError: 'Error: ',
|
|
@@ -397,6 +418,10 @@ const TRANSLATIONS = {
|
|
|
397
418
|
recCount: '{n} recording(s)',
|
|
398
419
|
zoomOut: 'Zoom out',
|
|
399
420
|
zoomIn: 'Zoom in',
|
|
421
|
+
flvNoLib: 'ERR: flv.js could not be loaded',
|
|
422
|
+
flvLoadingLib: 'loading flv.js…',
|
|
423
|
+
flvNoMse: 'ERR: MSE not supported (browser too old?)',
|
|
424
|
+
flvConnecting: 'connecting…',
|
|
400
425
|
reload: 'Reload',
|
|
401
426
|
},
|
|
402
427
|
es: {
|
|
@@ -417,6 +442,8 @@ const TRANSLATIONS = {
|
|
|
417
442
|
tagAll: 'Todas',
|
|
418
443
|
recStart: 'Iniciar grabación',
|
|
419
444
|
recStop: 'Detener grabación',
|
|
445
|
+
audioOn: 'Activar audio',
|
|
446
|
+
audioOff: 'Silenciar audio',
|
|
420
447
|
recLoading: 'Cargando…',
|
|
421
448
|
recNone: 'Sin grabaciones.<br><small>Comprueba enableEventDps en la config del adaptador.</small>',
|
|
422
449
|
recError: 'Error: ',
|
|
@@ -424,6 +451,10 @@ const TRANSLATIONS = {
|
|
|
424
451
|
recCount: '{n} grabación(es)',
|
|
425
452
|
zoomOut: 'Alejar',
|
|
426
453
|
zoomIn: 'Acercar',
|
|
454
|
+
flvNoLib: 'ERR: no se pudo cargar flv.js',
|
|
455
|
+
flvLoadingLib: 'cargando flv.js…',
|
|
456
|
+
flvNoMse: 'ERR: MSE no compatible (¿navegador demasiado antiguo?)',
|
|
457
|
+
flvConnecting: 'conectando…',
|
|
427
458
|
reload: 'Recargar',
|
|
428
459
|
},
|
|
429
460
|
fr: {
|
|
@@ -444,6 +475,8 @@ const TRANSLATIONS = {
|
|
|
444
475
|
tagAll: 'Tout',
|
|
445
476
|
recStart: 'Démarrer l\'enregistrement',
|
|
446
477
|
recStop: 'Arrêter l\'enregistrement',
|
|
478
|
+
audioOn: 'Activer le son',
|
|
479
|
+
audioOff: 'Couper le son',
|
|
447
480
|
recLoading: 'Chargement…',
|
|
448
481
|
recNone: 'Aucun enregistrement.<br><small>Vérifiez enableEventDps dans la config de l\'adaptateur.</small>',
|
|
449
482
|
recError: 'Erreur : ',
|
|
@@ -451,6 +484,10 @@ const TRANSLATIONS = {
|
|
|
451
484
|
recCount: '{n} enregistrement(s)',
|
|
452
485
|
zoomOut: 'Dézoomer',
|
|
453
486
|
zoomIn: 'Zoomer',
|
|
487
|
+
flvNoLib: 'ERR: impossible de charger flv.js',
|
|
488
|
+
flvLoadingLib: 'chargement flv.js…',
|
|
489
|
+
flvNoMse: 'ERR: MSE non pris en charge (navigateur trop ancien ?)',
|
|
490
|
+
flvConnecting: 'connexion…',
|
|
454
491
|
reload: 'Recharger',
|
|
455
492
|
},
|
|
456
493
|
it: {
|
|
@@ -471,6 +508,8 @@ const TRANSLATIONS = {
|
|
|
471
508
|
tagAll: 'Tutte',
|
|
472
509
|
recStart: 'Avvia registrazione',
|
|
473
510
|
recStop: 'Ferma registrazione',
|
|
511
|
+
audioOn: 'Attiva audio',
|
|
512
|
+
audioOff: 'Disattiva audio',
|
|
474
513
|
recLoading: 'Caricamento…',
|
|
475
514
|
recNone: 'Nessuna registrazione.<br><small>Controlla enableEventDps nella config dell\'adattatore.</small>',
|
|
476
515
|
recError: 'Errore: ',
|
|
@@ -478,6 +517,10 @@ const TRANSLATIONS = {
|
|
|
478
517
|
recCount: '{n} registrazione(i)',
|
|
479
518
|
zoomOut: 'Rimpicciolisci',
|
|
480
519
|
zoomIn: 'Ingrandisci',
|
|
520
|
+
flvNoLib: 'ERR: impossibile caricare flv.js',
|
|
521
|
+
flvLoadingLib: 'caricamento flv.js…',
|
|
522
|
+
flvNoMse: 'ERR: MSE non supportato (browser troppo vecchio?)',
|
|
523
|
+
flvConnecting: 'connessione…',
|
|
481
524
|
reload: 'Ricarica',
|
|
482
525
|
},
|
|
483
526
|
nl: {
|
|
@@ -498,6 +541,8 @@ const TRANSLATIONS = {
|
|
|
498
541
|
tagAll: 'Alle',
|
|
499
542
|
recStart: 'Opname starten',
|
|
500
543
|
recStop: 'Opname stoppen',
|
|
544
|
+
audioOn: 'Audio inschakelen',
|
|
545
|
+
audioOff: 'Audio uitschakelen',
|
|
501
546
|
recLoading: 'Laden…',
|
|
502
547
|
recNone: 'Geen opnames.<br><small>Controleer enableEventDps in de adapterconfig.</small>',
|
|
503
548
|
recError: 'Fout: ',
|
|
@@ -505,6 +550,10 @@ const TRANSLATIONS = {
|
|
|
505
550
|
recCount: '{n} opname(s)',
|
|
506
551
|
zoomOut: 'Uitzoomen',
|
|
507
552
|
zoomIn: 'Inzoomen',
|
|
553
|
+
flvNoLib: 'ERR: flv.js kon niet worden geladen',
|
|
554
|
+
flvLoadingLib: 'flv.js laden…',
|
|
555
|
+
flvNoMse: 'ERR: MSE niet ondersteund (browser te oud?)',
|
|
556
|
+
flvConnecting: 'verbinden…',
|
|
508
557
|
reload: 'Herladen',
|
|
509
558
|
},
|
|
510
559
|
pl: {
|
|
@@ -525,6 +574,8 @@ const TRANSLATIONS = {
|
|
|
525
574
|
tagAll: 'Wszystkie',
|
|
526
575
|
recStart: 'Rozpocznij nagrywanie',
|
|
527
576
|
recStop: 'Zatrzymaj nagrywanie',
|
|
577
|
+
audioOn: 'Włącz dźwięk',
|
|
578
|
+
audioOff: 'Wycisz dźwięk',
|
|
528
579
|
recLoading: 'Ładowanie…',
|
|
529
580
|
recNone: 'Brak nagrań.<br><small>Sprawdź enableEventDps w konfiguracji adaptera.</small>',
|
|
530
581
|
recError: 'Błąd: ',
|
|
@@ -532,6 +583,10 @@ const TRANSLATIONS = {
|
|
|
532
583
|
recCount: '{n} nagranie(a)',
|
|
533
584
|
zoomOut: 'Oddal',
|
|
534
585
|
zoomIn: 'Przybliż',
|
|
586
|
+
flvNoLib: 'ERR: nie można załadować flv.js',
|
|
587
|
+
flvLoadingLib: 'ładowanie flv.js…',
|
|
588
|
+
flvNoMse: 'ERR: MSE nieobsługiwane (za stara przeglądarka?)',
|
|
589
|
+
flvConnecting: 'łączenie…',
|
|
535
590
|
reload: 'Odśwież',
|
|
536
591
|
},
|
|
537
592
|
pt: {
|
|
@@ -552,6 +607,8 @@ const TRANSLATIONS = {
|
|
|
552
607
|
tagAll: 'Todas',
|
|
553
608
|
recStart: 'Iniciar gravação',
|
|
554
609
|
recStop: 'Parar gravação',
|
|
610
|
+
audioOn: 'Ativar áudio',
|
|
611
|
+
audioOff: 'Silenciar áudio',
|
|
555
612
|
recLoading: 'Carregando…',
|
|
556
613
|
recNone: 'Sem gravações.<br><small>Verifique enableEventDps na config do adaptador.</small>',
|
|
557
614
|
recError: 'Erro: ',
|
|
@@ -559,6 +616,10 @@ const TRANSLATIONS = {
|
|
|
559
616
|
recCount: '{n} gravação(ões)',
|
|
560
617
|
zoomOut: 'Afastar',
|
|
561
618
|
zoomIn: 'Aproximar',
|
|
619
|
+
flvNoLib: 'ERR: não foi possível carregar flv.js',
|
|
620
|
+
flvLoadingLib: 'a carregar flv.js…',
|
|
621
|
+
flvNoMse: 'ERR: MSE não suportado (navegador muito antigo?)',
|
|
622
|
+
flvConnecting: 'a conectar…',
|
|
562
623
|
reload: 'Recarregar',
|
|
563
624
|
},
|
|
564
625
|
ru: {
|
|
@@ -579,6 +640,8 @@ const TRANSLATIONS = {
|
|
|
579
640
|
tagAll: 'Все',
|
|
580
641
|
recStart: 'Начать запись',
|
|
581
642
|
recStop: 'Остановить запись',
|
|
643
|
+
audioOn: 'Включить звук',
|
|
644
|
+
audioOff: 'Выключить звук',
|
|
582
645
|
recLoading: 'Загрузка…',
|
|
583
646
|
recNone: 'Нет записей.<br><small>Проверьте enableEventDps в настройках адаптера.</small>',
|
|
584
647
|
recError: 'Ошибка: ',
|
|
@@ -586,6 +649,10 @@ const TRANSLATIONS = {
|
|
|
586
649
|
recCount: '{n} запись(ей)',
|
|
587
650
|
zoomOut: 'Уменьшить',
|
|
588
651
|
zoomIn: 'Увеличить',
|
|
652
|
+
flvNoLib: 'ОШИБКА: не удалось загрузить flv.js',
|
|
653
|
+
flvLoadingLib: 'загрузка flv.js…',
|
|
654
|
+
flvNoMse: 'ОШИБКА: MSE не поддерживается (браузер устарел?)',
|
|
655
|
+
flvConnecting: 'подключение…',
|
|
589
656
|
reload: 'Обновить',
|
|
590
657
|
},
|
|
591
658
|
uk: {
|
|
@@ -606,6 +673,8 @@ const TRANSLATIONS = {
|
|
|
606
673
|
tagAll: 'Всі',
|
|
607
674
|
recStart: 'Почати запис',
|
|
608
675
|
recStop: 'Зупинити запис',
|
|
676
|
+
audioOn: 'Увімкнути звук',
|
|
677
|
+
audioOff: 'Вимкнути звук',
|
|
609
678
|
recLoading: 'Завантаження…',
|
|
610
679
|
recNone: 'Немає записів.<br><small>Перевірте enableEventDps у налаштуваннях адаптера.</small>',
|
|
611
680
|
recError: 'Помилка: ',
|
|
@@ -613,6 +682,10 @@ const TRANSLATIONS = {
|
|
|
613
682
|
recCount: '{n} запис(ів)',
|
|
614
683
|
zoomOut: 'Зменшити',
|
|
615
684
|
zoomIn: 'Збільшити',
|
|
685
|
+
flvNoLib: 'ПОМИЛКА: не вдалося завантажити flv.js',
|
|
686
|
+
flvLoadingLib: 'завантаження flv.js…',
|
|
687
|
+
flvNoMse: 'ПОМИЛКА: MSE не підтримується (браузер застарілий?)',
|
|
688
|
+
flvConnecting: 'підключення…',
|
|
616
689
|
reload: 'Оновити',
|
|
617
690
|
},
|
|
618
691
|
'zh-cn': {
|
|
@@ -633,6 +706,8 @@ const TRANSLATIONS = {
|
|
|
633
706
|
tagAll: '全部',
|
|
634
707
|
recStart: '开始录制',
|
|
635
708
|
recStop: '停止录制',
|
|
709
|
+
audioOn: '开启声音',
|
|
710
|
+
audioOff: '关闭声音',
|
|
636
711
|
recLoading: '加载中…',
|
|
637
712
|
recNone: '无录像。<br><small>请检查适配器配置中的 enableEventDps。</small>',
|
|
638
713
|
recError: '错误:',
|
|
@@ -640,6 +715,10 @@ const TRANSLATIONS = {
|
|
|
640
715
|
recCount: '{n} 个录像',
|
|
641
716
|
zoomOut: '缩小',
|
|
642
717
|
zoomIn: '放大',
|
|
718
|
+
flvNoLib: 'ERR: 无法加载 flv.js',
|
|
719
|
+
flvLoadingLib: '正在加载 flv.js…',
|
|
720
|
+
flvNoMse: 'ERR: 不支持 MSE(浏览器版本过旧?)',
|
|
721
|
+
flvConnecting: '连接中…',
|
|
643
722
|
reload: '重新加载',
|
|
644
723
|
},
|
|
645
724
|
};
|
|
@@ -688,6 +767,58 @@ if (!TL_ZOOMS.includes(tlZoomLevel)) tlZoomLevel = 1;
|
|
|
688
767
|
let ptzHeld = null;
|
|
689
768
|
let refreshTimer = null;
|
|
690
769
|
let go2rtcMap = {}; // populated from CFG.go2rtcMapping after loadConfig()
|
|
770
|
+
const flvPlayers = {}; // camKey → flvjs.Player
|
|
771
|
+
let go2rtcStreams = []; // stream names fetched from go2rtc
|
|
772
|
+
|
|
773
|
+
// Per-camera stream type from cameraStreams config.
|
|
774
|
+
// Returns { stype: 'mjpeg'|'mp4'|'go2rtc', g2mStream: string }
|
|
775
|
+
function camStype(key) {
|
|
776
|
+
const src = (CFG.cameraStreams || {})[key] || 'mjpeg';
|
|
777
|
+
if (src === 'mp4') return { stype: 'mp4', g2mStream: '' };
|
|
778
|
+
if (src !== 'mjpeg') return { stype: 'go2rtc', g2mStream: src };
|
|
779
|
+
return { stype: 'mjpeg', g2mStream: '' };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function flvShowStatus(vid, msg) {
|
|
783
|
+
const p = vid.parentElement; if (!p) return;
|
|
784
|
+
let el = p.querySelector('.flv-status');
|
|
785
|
+
if (!el) {
|
|
786
|
+
el = document.createElement('div');
|
|
787
|
+
el.className = 'flv-status';
|
|
788
|
+
el.style.cssText = 'position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.75);color:#fa0;font-size:10px;padding:2px 4px;z-index:10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none';
|
|
789
|
+
p.style.position = 'relative';
|
|
790
|
+
p.appendChild(el);
|
|
791
|
+
}
|
|
792
|
+
el.textContent = msg;
|
|
793
|
+
}
|
|
794
|
+
function createFlvPlayer(vid, url, key) {
|
|
795
|
+
if (flvPlayers[key]) { try { flvPlayers[key].destroy(); } catch(_) {} delete flvPlayers[key]; }
|
|
796
|
+
if (!window.flvjs) {
|
|
797
|
+
if (window._flvFailed) { flvShowStatus(vid, t('flvNoLib')); return null; }
|
|
798
|
+
// Still loading — retry after promise resolves
|
|
799
|
+
flvShowStatus(vid, t('flvLoadingLib'));
|
|
800
|
+
(window._flvReady || Promise.resolve()).then(() => { if (!flvPlayers[key]) createFlvPlayer(vid, url, key); });
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
if (!flvjs.isSupported()) { flvShowStatus(vid, t('flvNoMse')); return null; }
|
|
804
|
+
flvShowStatus(vid, t('flvConnecting'));
|
|
805
|
+
const p = flvjs.createPlayer({ type: 'flv', isLive: true, url, enableWorker: false });
|
|
806
|
+
p.on(flvjs.Events.ERROR, (errType, errDetail) => {
|
|
807
|
+
flvShowStatus(vid, 'ERR ' + errType + ': ' + (errDetail && errDetail.msg ? errDetail.msg : JSON.stringify(errDetail)));
|
|
808
|
+
if (CFG.dashStreamReconnect !== false && !document.hidden) {
|
|
809
|
+
setTimeout(() => { if (!document.hidden) createFlvPlayer(vid, url, key); }, 5000);
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
p.on(flvjs.Events.MEDIA_INFO, () => { flvShowStatus(vid, ''); });
|
|
813
|
+
p.attachMediaElement(vid);
|
|
814
|
+
p.load();
|
|
815
|
+
p.play();
|
|
816
|
+
flvPlayers[key] = p;
|
|
817
|
+
return p;
|
|
818
|
+
}
|
|
819
|
+
function destroyFlvPlayer(key) {
|
|
820
|
+
if (flvPlayers[key]) { try { flvPlayers[key].destroy(); } catch(_) {} delete flvPlayers[key]; }
|
|
821
|
+
}
|
|
691
822
|
|
|
692
823
|
/* ─────────────────────────── Dashboard config ──────────────── */
|
|
693
824
|
let CFG = {
|
|
@@ -705,9 +836,12 @@ let CFG = {
|
|
|
705
836
|
dashColorOnline: '',
|
|
706
837
|
dashColorOffline: '',
|
|
707
838
|
dashTagPosition: 'top-right',
|
|
839
|
+
dashStreamType: 'mjpeg',
|
|
708
840
|
go2rtcEnabled: false,
|
|
709
841
|
go2rtcUrl: '',
|
|
710
842
|
go2rtcMapping: [],
|
|
843
|
+
cameraStreams: {},
|
|
844
|
+
dashLiveSource: 'agentdvr',
|
|
711
845
|
};
|
|
712
846
|
const DASH_CSS_VARS = {
|
|
713
847
|
dashColorBg: '--bg',
|
|
@@ -728,6 +862,11 @@ async function loadConfig() {
|
|
|
728
862
|
(Array.isArray(CFG.go2rtcMapping) ? CFG.go2rtcMapping : []).forEach(row => {
|
|
729
863
|
if (row.camKey && row.stream) go2rtcMap[row.camKey] = row.stream;
|
|
730
864
|
});
|
|
865
|
+
// Per-camera assignments from new cameraStreams config (non-mjpeg/mp4 = go2rtc stream)
|
|
866
|
+
Object.entries(CFG.cameraStreams || {}).forEach(function(entry) {
|
|
867
|
+
var camKey = entry[0], src = entry[1];
|
|
868
|
+
if (src && src !== 'mjpeg' && src !== 'mp4') go2rtcMap[camKey] = src;
|
|
869
|
+
});
|
|
731
870
|
for (const [key, prop] of Object.entries(DASH_CSS_VARS)) {
|
|
732
871
|
if (CFG[key]) document.documentElement.style.setProperty(prop, CFG[key]);
|
|
733
872
|
}
|
|
@@ -742,8 +881,14 @@ async function loadConfig() {
|
|
|
742
881
|
/* ─────────────────────────── Init ──────────────────────────── */
|
|
743
882
|
async function loadData() {
|
|
744
883
|
sbar(t('loadingCameras'));
|
|
884
|
+
// Fetch AgentDVR cameras and go2rtc streams in parallel
|
|
885
|
+
const [camRes] = await Promise.allSettled([
|
|
886
|
+
fetch('/agent-dvr/api/cameras', { signal: AbortSignal.timeout(8000) }),
|
|
887
|
+
loadGo2rtcStreams(), // populates go2rtcStreams, calls syncGrid when done
|
|
888
|
+
]);
|
|
889
|
+
|
|
745
890
|
try {
|
|
746
|
-
const r =
|
|
891
|
+
const r = camRes.status === 'fulfilled' ? camRes.value : (() => { throw camRes.reason; })();
|
|
747
892
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
748
893
|
const d = await r.json();
|
|
749
894
|
if (d.error) throw new Error(d.error);
|
|
@@ -756,14 +901,14 @@ async function loadData() {
|
|
|
756
901
|
if (!n) {
|
|
757
902
|
showBanner(t('bannerNoCameras'), true);
|
|
758
903
|
sbar(t('sbarNoCameras') + INST, true);
|
|
759
|
-
|
|
904
|
+
} else {
|
|
905
|
+
hideBanner();
|
|
906
|
+
sbar(n + t('sbarLoaded') + INST + (BASE ? ' · ' + BASE : ''));
|
|
907
|
+
buildRecCamSel();
|
|
908
|
+
trySocket();
|
|
760
909
|
}
|
|
761
|
-
hideBanner();
|
|
762
|
-
sbar(n + t('sbarLoaded') + INST + (BASE ? ' · ' + BASE : ''));
|
|
763
910
|
syncChips();
|
|
764
911
|
syncGrid();
|
|
765
|
-
buildRecCamSel();
|
|
766
|
-
trySocket();
|
|
767
912
|
if (!refreshTimer) showView(CFG.dashDefaultView || 'live'); // only on first load
|
|
768
913
|
// Periodic refresh
|
|
769
914
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
@@ -850,47 +995,69 @@ function syncGrid() {
|
|
|
850
995
|
return true;
|
|
851
996
|
});
|
|
852
997
|
|
|
853
|
-
//
|
|
854
|
-
|
|
855
|
-
|
|
998
|
+
// Save existing live tiles so we can re-insert them without restarting streams
|
|
999
|
+
const savedTiles = {};
|
|
1000
|
+
grid.querySelectorAll('.cam-tile[data-cam]').forEach(t => { savedTiles[t.dataset.cam] = t; });
|
|
1001
|
+
// Remove tiles that are no longer needed (disconnect their streams)
|
|
1002
|
+
Object.entries(savedTiles).forEach(([k, t]) => {
|
|
1003
|
+
if (!want.includes(k)) { t.querySelectorAll('video').forEach(v => disconnectWebRTC(v)); }
|
|
856
1004
|
});
|
|
1005
|
+
grid.innerHTML = '';
|
|
857
1006
|
|
|
858
|
-
// Add missing tiles, update recording state on existing ones
|
|
859
|
-
const have = new Set([...grid.querySelectorAll('.cam-tile')].map(t => t.dataset.cam));
|
|
860
1007
|
want.forEach(k => {
|
|
861
|
-
|
|
862
|
-
|
|
1008
|
+
const existing = savedTiles[k];
|
|
1009
|
+
if (existing) {
|
|
1010
|
+
const rb = existing.querySelector('.rec-btn');
|
|
1011
|
+
if (rb) setTileRecording(existing, rb, !!cameras[k].recording);
|
|
1012
|
+
grid.appendChild(existing);
|
|
863
1013
|
} else {
|
|
864
|
-
|
|
865
|
-
if (t) { const rb = t.querySelector('.rec-btn'); if (rb) setTileRecording(t, rb, !!cameras[k].recording); }
|
|
1014
|
+
grid.appendChild(makeTile(k, cameras[k]));
|
|
866
1015
|
}
|
|
867
1016
|
});
|
|
868
1017
|
|
|
869
|
-
if (!grid.querySelector('.cam-tile')) {
|
|
870
|
-
grid.innerHTML = `<div class="empty-state">
|
|
871
|
-
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
|
872
|
-
<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/>
|
|
873
|
-
<line x1="2" y1="2" x2="22" y2="22" stroke-width="1.5"/>
|
|
874
|
-
</svg>Keine Kameras sichtbar</div>`;
|
|
875
|
-
}
|
|
876
1018
|
updateLayout();
|
|
877
1019
|
}
|
|
878
1020
|
|
|
1021
|
+
|
|
1022
|
+
/* ─────────────────────────── go2rtc streams laden ──────────── */
|
|
1023
|
+
async function loadGo2rtcStreams() {
|
|
1024
|
+
try {
|
|
1025
|
+
const r = await fetch('/agent-dvr/api/go2rtc/streams', { signal: AbortSignal.timeout(5000) });
|
|
1026
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
1027
|
+
const d = await r.json();
|
|
1028
|
+
go2rtcStreams = (d.streams || []).sort();
|
|
1029
|
+
} catch (_) { go2rtcStreams = []; }
|
|
1030
|
+
syncGrid();
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
|
|
879
1034
|
function makeTile(key, cam) {
|
|
880
1035
|
const tile = document.createElement('div');
|
|
881
1036
|
tile.className = 'cam-tile'; tile.dataset.cam = key;
|
|
882
1037
|
|
|
883
|
-
// Decide stream type:
|
|
884
|
-
const
|
|
885
|
-
const useWrtc = CFG.go2rtcEnabled && CFG.go2rtcUrl && !!g2mStream;
|
|
1038
|
+
// Decide stream type: per-camera cameraStreams config
|
|
1039
|
+
const { stype, g2mStream } = camStype(key);
|
|
886
1040
|
|
|
887
|
-
let mediaEl;
|
|
888
|
-
|
|
1041
|
+
let mediaEl, sizeParam = '';
|
|
1042
|
+
|
|
1043
|
+
if (stype === 'go2rtc') {
|
|
889
1044
|
const vid = document.createElement('video');
|
|
890
1045
|
vid.autoplay = true; vid.muted = true; vid.playsInline = true;
|
|
891
1046
|
vid.dataset.stream = g2mStream;
|
|
892
1047
|
connectWebRTC(vid, CFG.go2rtcUrl, g2mStream);
|
|
893
1048
|
mediaEl = vid;
|
|
1049
|
+
} else if (stype === 'mp4') {
|
|
1050
|
+
const vid = document.createElement('video');
|
|
1051
|
+
vid.autoplay = true; vid.muted = true; vid.playsInline = true;
|
|
1052
|
+
vid.setAttribute('muted', '');
|
|
1053
|
+
vid.dataset.camKey = key;
|
|
1054
|
+
vid.dataset.flvOid = cam.oid || '';
|
|
1055
|
+
// Request the stream at the camera's native resolution so AgentDVR encodes
|
|
1056
|
+
// it at the correct AR — no CSS tricks needed.
|
|
1057
|
+
const arParts = (cam.aspect || '').split('/').map(Number);
|
|
1058
|
+
if (arParts.length === 2 && arParts[0] >= 100 && arParts[1] >= 100)
|
|
1059
|
+
sizeParam = '&size=' + arParts[0] + 'x' + arParts[1];
|
|
1060
|
+
mediaEl = vid;
|
|
894
1061
|
} else {
|
|
895
1062
|
const img = document.createElement('img'); img.alt = cam.name;
|
|
896
1063
|
if (cam.mjpegUrl) {
|
|
@@ -939,12 +1106,25 @@ function makeTile(key, cam) {
|
|
|
939
1106
|
tile.appendChild(makePtzOverlay(key));
|
|
940
1107
|
}
|
|
941
1108
|
|
|
1109
|
+
if (stype !== 'mjpeg') {
|
|
1110
|
+
const muteBtn = document.createElement('div');
|
|
1111
|
+
muteBtn.className = 'tile-btn mute-btn';
|
|
1112
|
+
muteBtn.title = t('audioOn');
|
|
1113
|
+
muteBtn.innerHTML = SVG_AUDIO_OFF;
|
|
1114
|
+
muteBtn.addEventListener('click', e => { e.stopPropagation(); toggleMute(mediaEl, muteBtn, key); });
|
|
1115
|
+
btnBox.appendChild(muteBtn);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
942
1118
|
tile.appendChild(btnBox);
|
|
943
1119
|
if (cam.detected) tile.classList.add('detected');
|
|
944
1120
|
if (cam.alerted) tile.classList.add('alerted');
|
|
945
1121
|
if (cam.recording) setTileRecording(tile, recBtn, true);
|
|
946
1122
|
|
|
947
1123
|
tile.append(mediaEl, bottom);
|
|
1124
|
+
if (stype === 'mp4' && cam.oid) {
|
|
1125
|
+
const _el = mediaEl, _oid = cam.oid, _key = key, _size = sizeParam;
|
|
1126
|
+
setTimeout(() => createFlvPlayer(_el, '/agent-dvr/api/stream?oid=' + _oid + _size, _key), 0);
|
|
1127
|
+
}
|
|
948
1128
|
return tile;
|
|
949
1129
|
}
|
|
950
1130
|
|
|
@@ -1067,9 +1247,12 @@ function updateLayout() {
|
|
|
1067
1247
|
if (!tiles) return;
|
|
1068
1248
|
const W = grid.clientWidth, H = grid.clientHeight;
|
|
1069
1249
|
if (W < 10 || H < 10) return;
|
|
1070
|
-
const
|
|
1250
|
+
const gap = 6;
|
|
1251
|
+
const { cols, rows } = calcLayout(tiles, W, H, gap);
|
|
1252
|
+
const colW = (W - gap * (cols - 1)) / cols;
|
|
1253
|
+
const rowH = Math.floor(colW * 9 / 16);
|
|
1071
1254
|
grid.style.gridTemplateColumns = `repeat(${cols},1fr)`;
|
|
1072
|
-
grid.style.gridTemplateRows = `repeat(${rows}
|
|
1255
|
+
grid.style.gridTemplateRows = `repeat(${rows},${rowH}px)`;
|
|
1073
1256
|
}
|
|
1074
1257
|
function ensureLayoutObserver() {
|
|
1075
1258
|
if (layoutObserver) return;
|
|
@@ -1111,6 +1294,28 @@ function setTileRecording(tile, btn, recording) {
|
|
|
1111
1294
|
}
|
|
1112
1295
|
}
|
|
1113
1296
|
}
|
|
1297
|
+
const SVG_AUDIO_OFF = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>`;
|
|
1298
|
+
const SVG_AUDIO_ON = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>`;
|
|
1299
|
+
|
|
1300
|
+
function applyMuteState(video, btn, muted) {
|
|
1301
|
+
video.muted = muted;
|
|
1302
|
+
video.volume = muted ? 0 : 1;
|
|
1303
|
+
if (!muted) { video.play && video.play().catch(() => {}); }
|
|
1304
|
+
btn.classList.toggle('active', !muted);
|
|
1305
|
+
btn.innerHTML = muted ? SVG_AUDIO_OFF : SVG_AUDIO_ON;
|
|
1306
|
+
btn.title = muted ? t('audioOn') : t('audioOff');
|
|
1307
|
+
}
|
|
1308
|
+
function toggleMute(video, btn, key) {
|
|
1309
|
+
const muted = !video.muted;
|
|
1310
|
+
applyMuteState(video, btn, muted);
|
|
1311
|
+
// sync sibling (tile ↔ fullscreen)
|
|
1312
|
+
const tileVid = document.querySelector('.cam-tile[data-cam="' + key + '"] video');
|
|
1313
|
+
const tileBtn = document.querySelector('.cam-tile[data-cam="' + key + '"] .mute-btn');
|
|
1314
|
+
const fsVid = document.getElementById('fs-vid');
|
|
1315
|
+
const fsBtn = document.querySelector('#fs-btns .mute-btn');
|
|
1316
|
+
if (video !== tileVid && tileVid && tileBtn) applyMuteState(tileVid, tileBtn, muted);
|
|
1317
|
+
if (video !== fsVid && fsVid && fsBtn && document.getElementById('fs-panel').dataset.cam === key) applyMuteState(fsVid, fsBtn, muted);
|
|
1318
|
+
}
|
|
1114
1319
|
function toggleRec(key, btn, tile) {
|
|
1115
1320
|
const isRec = btn.classList.contains('active');
|
|
1116
1321
|
fetch('/agent-dvr/api/record?camKey=' + encodeURIComponent(key) + '&action=' + (isRec ? 'stop' : 'start'))
|
|
@@ -1128,14 +1333,20 @@ function openFs(cam, key) {
|
|
|
1128
1333
|
// Stop any running fullscreen stream first
|
|
1129
1334
|
fi.src = '';
|
|
1130
1335
|
disconnectWebRTC(fv);
|
|
1336
|
+
destroyFlvPlayer('fs_' + key);
|
|
1131
1337
|
|
|
1132
|
-
//
|
|
1133
|
-
const
|
|
1134
|
-
|
|
1135
|
-
if (useWrtc) {
|
|
1338
|
+
// Decide stream type for fullscreen: per-camera cameraStreams config
|
|
1339
|
+
const { stype, g2mStream } = camStype(key);
|
|
1340
|
+
if (stype === 'go2rtc') {
|
|
1136
1341
|
fi.style.display = 'none';
|
|
1137
1342
|
fv.style.display = 'block';
|
|
1138
1343
|
connectWebRTC(fv, CFG.go2rtcUrl, g2mStream);
|
|
1344
|
+
} else if (stype === 'mp4') {
|
|
1345
|
+
fi.style.display = 'none';
|
|
1346
|
+
fv.style.display = 'block';
|
|
1347
|
+
fv.dataset.camKey = key;
|
|
1348
|
+
fv.style.objectFit = 'contain';
|
|
1349
|
+
createFlvPlayer(fv, '/agent-dvr/api/stream?oid=' + cam.oid, 'fs_' + key);
|
|
1139
1350
|
} else {
|
|
1140
1351
|
fi.style.display = 'block';
|
|
1141
1352
|
fv.style.display = 'none';
|
|
@@ -1181,13 +1392,28 @@ function openFs(cam, key) {
|
|
|
1181
1392
|
});
|
|
1182
1393
|
}
|
|
1183
1394
|
|
|
1395
|
+
if (stype !== 'mjpeg') {
|
|
1396
|
+
const fvEl = document.getElementById('fs-vid');
|
|
1397
|
+
const tileVid = gridTile ? gridTile.querySelector('video') : null;
|
|
1398
|
+
const isMuted = tileVid ? tileVid.muted : true;
|
|
1399
|
+
fvEl.muted = isMuted;
|
|
1400
|
+
const muteBtn = document.createElement('div');
|
|
1401
|
+
muteBtn.className = 'tile-btn mute-btn';
|
|
1402
|
+
applyMuteState(fvEl, muteBtn, isMuted);
|
|
1403
|
+
muteBtn.addEventListener('click', e => { e.stopPropagation(); toggleMute(fvEl, muteBtn, key); });
|
|
1404
|
+
btnBox.appendChild(muteBtn);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1184
1407
|
fs.classList.add('open');
|
|
1185
1408
|
}
|
|
1186
1409
|
function closeFs() {
|
|
1187
1410
|
const fi = document.getElementById('fs-img');
|
|
1188
1411
|
const fv = document.getElementById('fs-vid');
|
|
1412
|
+
const fsCam = document.getElementById('fs-panel').dataset.cam;
|
|
1189
1413
|
fi.src = ''; fi.style.display = 'block';
|
|
1190
|
-
disconnectWebRTC(fv);
|
|
1414
|
+
disconnectWebRTC(fv);
|
|
1415
|
+
if (fsCam) destroyFlvPlayer('fs_' + fsCam);
|
|
1416
|
+
fv.style.display = 'none';
|
|
1191
1417
|
const fs = document.getElementById('fs-panel');
|
|
1192
1418
|
fs.classList.remove('open', 'recording');
|
|
1193
1419
|
fs.dataset.cam = '';
|
|
@@ -1499,11 +1725,17 @@ document.addEventListener('visibilitychange', () => {
|
|
|
1499
1725
|
});
|
|
1500
1726
|
// Stop WebRTC on tiles
|
|
1501
1727
|
document.querySelectorAll('.cam-tile video[data-stream]').forEach(vid => disconnectWebRTC(vid));
|
|
1728
|
+
// Stop FLV on tiles
|
|
1729
|
+
document.querySelectorAll('.cam-tile video[data-cam-key]').forEach(vid => {
|
|
1730
|
+
destroyFlvPlayer(vid.dataset.camKey);
|
|
1731
|
+
});
|
|
1502
1732
|
// Stop fullscreen streams
|
|
1503
1733
|
const fi = document.getElementById('fs-img');
|
|
1504
1734
|
if (fi.src) { fi.dataset.s = fi.src; fi.src = ''; }
|
|
1505
1735
|
const fv = document.getElementById('fs-vid');
|
|
1506
1736
|
if (fv && fv.dataset.stream) disconnectWebRTC(fv);
|
|
1737
|
+
const fsCam = document.getElementById('fs-panel').dataset.cam;
|
|
1738
|
+
if (fsCam) destroyFlvPlayer('fs_' + fsCam);
|
|
1507
1739
|
} else {
|
|
1508
1740
|
// Restart MJPEG
|
|
1509
1741
|
document.querySelectorAll('.cam-tile img[data-mjpeg]').forEach(img => {
|
|
@@ -1515,75 +1747,126 @@ document.addEventListener('visibilitychange', () => {
|
|
|
1515
1747
|
document.querySelectorAll('.cam-tile video[data-stream]').forEach(vid =>
|
|
1516
1748
|
connectWebRTC(vid, CFG.go2rtcUrl, vid.dataset.stream));
|
|
1517
1749
|
}
|
|
1750
|
+
// Restart FLV on tiles
|
|
1751
|
+
document.querySelectorAll('.cam-tile video[data-cam-key]').forEach(vid => {
|
|
1752
|
+
const k = vid.dataset.camKey;
|
|
1753
|
+
const cam = cameras[k];
|
|
1754
|
+
if (cam) createFlvPlayer(vid, '/agent-dvr/api/stream?oid=' + cam.oid, k);
|
|
1755
|
+
});
|
|
1518
1756
|
// Restart fullscreen streams
|
|
1519
1757
|
const fi = document.getElementById('fs-img');
|
|
1520
1758
|
if (!fi.src && fi.dataset.s) { fi.src = fi.dataset.s; }
|
|
1521
1759
|
const fv = document.getElementById('fs-vid');
|
|
1522
|
-
|
|
1760
|
+
const fsCam = document.getElementById('fs-panel').dataset.cam;
|
|
1761
|
+
if (fsCam && cameras[fsCam]) {
|
|
1762
|
+
const { stype: fsStype, g2mStream: fsG2m } = camStype(fsCam);
|
|
1763
|
+
if (fsStype === 'go2rtc' && CFG.go2rtcUrl && fsG2m)
|
|
1764
|
+
connectWebRTC(fv, CFG.go2rtcUrl, fsG2m);
|
|
1765
|
+
else if (fsStype === 'mp4')
|
|
1766
|
+
createFlvPlayer(fv, '/agent-dvr/api/stream?oid=' + cameras[fsCam].oid, 'fs_' + fsCam);
|
|
1767
|
+
}
|
|
1523
1768
|
}
|
|
1524
1769
|
});
|
|
1525
1770
|
|
|
1526
|
-
/* ───────────────────────────
|
|
1527
|
-
|
|
1771
|
+
/* ─────────────────────────── go2rtc (MSE) ───────────────────── */
|
|
1772
|
+
function connectWebRTC(vid, baseUrl, streamName) {
|
|
1528
1773
|
disconnectWebRTC(vid);
|
|
1529
1774
|
if (!baseUrl || !streamName) return;
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
.then(o => pc.setLocalDescription(o))
|
|
1559
|
-
.then(() => {
|
|
1560
|
-
if (ws.readyState === WebSocket.OPEN)
|
|
1561
|
-
ws.send(JSON.stringify({type:'webrtc/offer', value:pc.localDescription.sdp}));
|
|
1562
|
-
});
|
|
1563
|
-
});
|
|
1564
|
-
ws.addEventListener('message', ev => {
|
|
1565
|
-
try {
|
|
1566
|
-
const msg = JSON.parse(ev.data);
|
|
1567
|
-
if (msg.type === 'webrtc/candidate') {
|
|
1568
|
-
pc.addIceCandidate({candidate:msg.value, sdpMid:'0'}).catch(() => {});
|
|
1569
|
-
} else if (msg.type === 'webrtc/answer') {
|
|
1570
|
-
console.log('[go2rtc] got answer for', streamName);
|
|
1571
|
-
pc.setRemoteDescription({type:'answer', sdp:msg.value});
|
|
1572
|
-
}
|
|
1573
|
-
} catch (_) {}
|
|
1574
|
-
});
|
|
1575
|
-
ws.addEventListener('error', () => console.warn('[go2rtc] WS error for stream', streamName));
|
|
1576
|
-
ws.addEventListener('close', ev => console.warn('[go2rtc] WS closed', ev.code, streamName));
|
|
1577
|
-
pc.addEventListener('connectionstatechange', () =>
|
|
1578
|
-
console.log('[go2rtc] PC state:', pc.connectionState, streamName));
|
|
1579
|
-
} catch (err) {
|
|
1580
|
-
console.error('[go2rtc] connectWebRTC failed:', err);
|
|
1775
|
+
const MS = window.ManagedMediaSource || window.MediaSource;
|
|
1776
|
+
if (!MS) return;
|
|
1777
|
+
|
|
1778
|
+
// Include Baseline H.264 (avc1.42e01f) so go2rtc confirms video+audio
|
|
1779
|
+
const ALL_CODECS = [
|
|
1780
|
+
'avc1.42e01f', 'avc1.640029', 'avc1.64002A', 'avc1.640033',
|
|
1781
|
+
'hvc1.1.6.L153.B0', 'mp4a.40.2', 'mp4a.40.5', 'opus',
|
|
1782
|
+
];
|
|
1783
|
+
const supported = ALL_CODECS.filter(c => {
|
|
1784
|
+
try { return MS.isTypeSupported('video/mp4; codecs="' + c + '"'); } catch (_) { return false; }
|
|
1785
|
+
});
|
|
1786
|
+
const codecReq = 'video/mp4; codecs="' + supported.join(',') + '"';
|
|
1787
|
+
|
|
1788
|
+
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1789
|
+
const wsUrl = wsProto + '//' + location.host + '/agent-dvr/api/ws?src=' + encodeURIComponent(streamName);
|
|
1790
|
+
const ws = new WebSocket(wsUrl);
|
|
1791
|
+
ws.binaryType = 'arraybuffer';
|
|
1792
|
+
vid._rtcWs = ws;
|
|
1793
|
+
vid.dataset.stream = streamName;
|
|
1794
|
+
|
|
1795
|
+
let sbReady = false;
|
|
1796
|
+
let sb = null;
|
|
1797
|
+
const queue = [];
|
|
1798
|
+
|
|
1799
|
+
function flush() {
|
|
1800
|
+
if (!sb || sb.updating || queue.length === 0) return;
|
|
1801
|
+
const data = queue.shift();
|
|
1802
|
+
try { sb.appendBuffer(data); } catch (_) { flush(); }
|
|
1581
1803
|
}
|
|
1804
|
+
|
|
1805
|
+
// Stall detection: if no video within 10 s after connect, retry
|
|
1806
|
+
const stallTimer = setTimeout(() => {
|
|
1807
|
+
if (vid._rtcWs === ws && vid.readyState < 2 && !document.hidden)
|
|
1808
|
+
connectWebRTC(vid, baseUrl, streamName);
|
|
1809
|
+
}, 10000);
|
|
1810
|
+
|
|
1811
|
+
ws.addEventListener('open', () => ws.send(JSON.stringify({type:'mse', value: codecReq})));
|
|
1812
|
+
|
|
1813
|
+
ws.addEventListener('message', ev => {
|
|
1814
|
+
if (typeof ev.data !== 'string') {
|
|
1815
|
+
// Binary: fMP4 fragment — always queue, flush only when SourceBuffer is ready
|
|
1816
|
+
queue.push(ev.data);
|
|
1817
|
+
if (sbReady) flush();
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
try {
|
|
1821
|
+
const msg = JSON.parse(ev.data);
|
|
1822
|
+
if (msg.type !== 'mse') return;
|
|
1823
|
+
const mimeType = msg.value;
|
|
1824
|
+
const mediaSrc = new MS();
|
|
1825
|
+
const onOpen = () => {
|
|
1826
|
+
try {
|
|
1827
|
+
sb = mediaSrc.addSourceBuffer(mimeType);
|
|
1828
|
+
sb.mode = 'segments';
|
|
1829
|
+
sb.addEventListener('updateend', () => {
|
|
1830
|
+
flush();
|
|
1831
|
+
// Trim live buffer: keep only last 5 s to avoid memory growth
|
|
1832
|
+
if (sb.buffered && sb.buffered.length) {
|
|
1833
|
+
const end = sb.buffered.end(sb.buffered.length - 1);
|
|
1834
|
+
const s0 = sb.buffered.start(0);
|
|
1835
|
+
if (end - s0 > 5) { try { sb.remove(s0, end - 3); } catch (_) {} }
|
|
1836
|
+
}
|
|
1837
|
+
});
|
|
1838
|
+
sbReady = true;
|
|
1839
|
+
flush(); // drain any frames that arrived before sb was ready
|
|
1840
|
+
} catch (e) { console.warn('[go2rtc] SourceBuffer error:', e); }
|
|
1841
|
+
};
|
|
1842
|
+
if ('ManagedMediaSource' in window) {
|
|
1843
|
+
vid.disableRemotePlayback = true;
|
|
1844
|
+
vid.srcObject = mediaSrc;
|
|
1845
|
+
} else {
|
|
1846
|
+
vid.src = URL.createObjectURL(mediaSrc);
|
|
1847
|
+
vid.srcObject = null;
|
|
1848
|
+
}
|
|
1849
|
+
mediaSrc.addEventListener('sourceopen', onOpen, {once: true});
|
|
1850
|
+
vid.muted = true;
|
|
1851
|
+
vid.play().catch(() => {});
|
|
1852
|
+
} catch (_) {}
|
|
1853
|
+
});
|
|
1854
|
+
|
|
1855
|
+
ws.addEventListener('error', () => {});
|
|
1856
|
+
ws.addEventListener('close', () => {
|
|
1857
|
+
clearTimeout(stallTimer);
|
|
1858
|
+
if (!ws._intentional && CFG.dashStreamReconnect !== false && !document.hidden && vid.dataset.stream === streamName) {
|
|
1859
|
+
setTimeout(() => {
|
|
1860
|
+
if (!document.hidden && vid.dataset.stream === streamName) connectWebRTC(vid, baseUrl, streamName);
|
|
1861
|
+
}, 5000);
|
|
1862
|
+
}
|
|
1863
|
+
});
|
|
1582
1864
|
}
|
|
1583
1865
|
function disconnectWebRTC(vid) {
|
|
1584
1866
|
if (!vid) return;
|
|
1585
|
-
try { if (vid._rtcWs) { vid._rtcWs.close(); vid._rtcWs = null; } } catch (_) {}
|
|
1867
|
+
try { if (vid._rtcWs) { vid._rtcWs._intentional = true; vid._rtcWs.close(); vid._rtcWs = null; } } catch (_) {}
|
|
1586
1868
|
try { if (vid._rtcPc) { vid._rtcPc.close(); vid._rtcPc = null; } } catch (_) {}
|
|
1869
|
+
if (vid.src && vid.src.startsWith('blob:')) { URL.revokeObjectURL(vid.src); vid.src = ''; }
|
|
1587
1870
|
vid.srcObject = null;
|
|
1588
1871
|
}
|
|
1589
1872
|
|