iobroker.agent-dvr 0.1.0 → 0.2.1

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/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:wrap;align-items:center}
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">Aufnahmen</span>
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="&#128269; Suchen…" oninput="filterRec()">
289
+ <input id="rec-search" type="search" data-i18n-placeholder="searchPlaceholder" placeholder="&#128269; Search…" oninput="filterRec()">
277
290
  <select id="rec-cam-sel" onchange="loadAndFilterRec()">
278
- <option value="" data-i18n="camSelect">Kamera wählen…</option>
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="Rasteransicht" onclick="setRecView('grid')">
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">Lade…</span></div>
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">&#8249; Vorherige</button>
329
+ <button id="vid-prev" onclick="playRecIdx(recPlayIdx - 1)" data-i18n="vidPrev">&#8249; Previous</button>
317
330
  <span id="vid-pos"></span>
318
- <button id="vid-next" onclick="playRecIdx(recPlayIdx + 1)" data-i18n="vidNext">Nächste &#8250;</button>
331
+ <button id="vid-next" onclick="playRecIdx(recPlayIdx + 1)" data-i18n="vidNext">Next &#8250;</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 = await fetch('/agent-dvr/api/cameras', { signal: AbortSignal.timeout(8000) });
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
- return;
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
- // Remove tiles no longer needed (without touching others streams keep running)
854
- [...grid.querySelectorAll('.cam-tile')].forEach(t => {
855
- if (!want.includes(t.dataset.cam)) t.remove();
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
- if (!have.has(k)) {
862
- grid.appendChild(makeTile(k, cameras[k]));
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
- const t = grid.querySelector('.cam-tile[data-cam="' + k + '"]');
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: WebRTC (go2rtc) or MJPEG/snapshot
884
- const g2mStream = typeof go2rtcMap[key] === 'string' ? go2rtcMap[key] : '';
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
- if (useWrtc) {
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 { cols, rows } = calcLayout(tiles, W, H, 6);
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},1fr)`;
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
- // Check if we should use WebRTC for this camera
1133
- const g2mStream = typeof go2rtcMap[key] === 'string' ? go2rtcMap[key] : '';
1134
- const useWrtc = CFG.go2rtcEnabled && CFG.go2rtcUrl && !!g2mStream;
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); fv.style.display = 'none';
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
- if (fv && fv.dataset.stream && CFG.go2rtcUrl) connectWebRTC(fv, CFG.go2rtcUrl, fv.dataset.stream);
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
- /* ─────────────────────────── WebRTC (go2rtc) ───────────────── */
1527
- async function connectWebRTC(vid, baseUrl, streamName) {
1771
+ /* ─────────────────────────── go2rtc (MSE) ───────────────────── */
1772
+ function connectWebRTC(vid, baseUrl, streamName) {
1528
1773
  disconnectWebRTC(vid);
1529
1774
  if (!baseUrl || !streamName) return;
1530
- try {
1531
- const pc = new RTCPeerConnection({
1532
- iceServers: [{urls:['stun:stun.cloudflare.com:3478','stun:stun.l.google.com:19302']}]
1533
- });
1534
- vid._rtcPc = pc;
1535
- vid.dataset.stream = streamName;
1536
-
1537
- const stream = new MediaStream();
1538
- vid.srcObject = stream;
1539
- pc.addEventListener('track', ev => stream.addTrack(ev.track));
1540
-
1541
- pc.addTransceiver('video', {direction:'recvonly'});
1542
- pc.addTransceiver('audio', {direction:'recvonly'});
1543
-
1544
- // Route through ioBroker proxy to avoid browser cross-origin block on go2rtc
1545
- const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1546
- const wsUrl = wsProto + '//' + location.host + '/agent-dvr/api/ws?src=' + encodeURIComponent(streamName);
1547
- console.log('[go2rtc] connecting via proxy', wsUrl);
1548
- const ws = new WebSocket(wsUrl);
1549
- vid._rtcWs = ws;
1550
-
1551
- ws.addEventListener('open', () => {
1552
- console.log('[go2rtc] WS open, creating offer for', streamName);
1553
- pc.addEventListener('icecandidate', ev => {
1554
- if (!ev.candidate || ws.readyState !== WebSocket.OPEN) return;
1555
- ws.send(JSON.stringify({type:'webrtc/candidate', value:ev.candidate.candidate}));
1556
- });
1557
- pc.createOffer()
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