iobroker.agent-dvr 0.0.4 → 0.0.6

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 ADDED
@@ -0,0 +1,1262 @@
1
+ <!DOCTYPE html>
2
+ <html lang="de">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>AgentDVR</title>
7
+ <style>
8
+ :root{
9
+ --bg:#080b0f;--s1:#0d1117;--s2:#131a22;--s3:#1a2332;
10
+ --border:#1e2a38;--border2:#253040;
11
+ --accent:#2563eb;--accent-l:#3b82f6;--accent-glow:rgba(37,99,235,.25);
12
+ --text:#dde4ef;--muted:#556070;--muted2:#3a4858;
13
+ --green:#22c55e;--red:#ef4444;--orange:#f97316;--yellow:#eab308;
14
+ --radius:8px;--gap:6px;
15
+ }
16
+ *{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
17
+ html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;font-size:14px}
18
+
19
+ /* ── Header ── */
20
+ #hdr{height:50px;background:rgba(8,11,15,.97);border-bottom:1px solid var(--border);
21
+ display:flex;align-items:center;gap:0;padding:0 14px;flex-shrink:0;backdrop-filter:blur(12px)}
22
+ #nav{display:flex;gap:2px}
23
+ .nav-btn{background:none;border:none;color:var(--muted);padding:6px 14px;border-radius:6px;
24
+ cursor:pointer;font-size:13px;font-weight:500;display:flex;align-items:center;gap:6px;transition:all .15s}
25
+ .nav-btn:hover{color:var(--text);background:var(--s2)}
26
+ .nav-btn.active{color:var(--accent-l);background:rgba(37,99,235,.12)}
27
+ .hdr-r{margin-left:auto;display:flex;align-items:center;gap:12px}
28
+ #conn-wrap{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
29
+ #conn-dot{width:7px;height:7px;border-radius:50%;background:var(--red);transition:background .4s;flex-shrink:0}
30
+ #conn-dot.on{background:var(--green);box-shadow:0 0 6px var(--green)}
31
+
32
+ /* ── Layout ── */
33
+ #app{display:flex;flex-direction:column;height:100dvh}
34
+ main{flex:1;overflow:hidden;min-height:0;position:relative}
35
+ .view{display:none;height:100%;flex-direction:column}
36
+ .view.active{display:flex}
37
+
38
+ /* ── Live view ── */
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}
41
+ .chip{background:var(--s1);border:1px solid var(--border);border-radius:99px;
42
+ padding:4px 12px;font-size:12px;cursor:pointer;user-select:none;color:var(--muted);
43
+ transition:all .15s;white-space:nowrap}
44
+ .chip:hover{border-color:var(--border2);color:var(--text)}
45
+ .chip.on{background:rgba(37,99,235,.15);border-color:var(--accent);color:var(--accent-l)}
46
+
47
+ #cam-grid{flex:1;min-height:0;display:grid;gap:var(--gap)}
48
+ .cam-tile{background:#000;border-radius:var(--radius);overflow:hidden;position:relative;
49
+ cursor:pointer;min-width:0;min-height:0;border:2px solid transparent;
50
+ transition:border-color .2s,box-shadow .2s}
51
+ .cam-tile:hover{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent),0 0 16px var(--accent-glow)}
52
+ .cam-tile.detected{border-color:var(--yellow);box-shadow:0 0 0 1px var(--yellow),0 0 14px rgba(234,179,8,.2)}
53
+ .cam-tile.alerted{border-color:var(--orange);box-shadow:0 0 0 1px var(--orange),0 0 14px rgba(249,115,22,.2)}
54
+ .cam-tile.recording{border-color:var(--red)!important;box-shadow:0 0 0 1px var(--red),0 0 14px rgba(239,68,68,.25)!important}
55
+ .cam-tile img{width:100%;height:100%;object-fit:contain;display:block}
56
+ .cam-bottom{position:absolute;bottom:0;left:0;right:0;
57
+ background:linear-gradient(transparent,rgba(0,0,0,.75));
58
+ padding:18px 8px 5px;display:flex;align-items:flex-end;justify-content:space-between;pointer-events:none}
59
+ .cam-name{font-size:11px;font-weight:500;text-shadow:0 1px 3px #000;white-space:nowrap;
60
+ overflow:hidden;text-overflow:ellipsis}
61
+ .cam-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;box-shadow:0 0 4px currentColor}
62
+ .cam-dot.on{background:var(--green);color:var(--green)}
63
+ .cam-dot.off{background:var(--red);color:var(--red)}
64
+
65
+ /* Tile buttons — opacity controlled by --btn-base-opacity (0=hover-only, 1=always visible) */
66
+ .tile-btns{position:absolute;top:7px;right:7px;display:flex;gap:4px;align-items:center;pointer-events:all}
67
+ .tile-btn{width:28px;height:28px;border-radius:6px;background:rgba(0,0,0,.78);border:1px solid rgba(255,255,255,.45);
68
+ color:#fff;font-size:14px;display:flex;align-items:center;justify-content:center;
69
+ cursor:pointer;opacity:var(--btn-base-opacity,0);transition:opacity .15s,background .15s;
70
+ box-shadow:0 0 0 1px rgba(0,0,0,.55),0 2px 8px rgba(0,0,0,.7)}
71
+ .cam-tile:hover .tile-btn{opacity:1}
72
+ .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
+ .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
+ .rec-btn:hover{background:rgba(239,68,68,.7)!important;border-color:var(--red)!important}
75
+ #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
+ #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
+ #fs-panel.recording::after{content:'';position:fixed;inset:0;pointer-events:none;box-shadow:inset 0 0 0 3px var(--red);z-index:1}
78
+
79
+ /* PTZ overlay */
80
+ .ptz-overlay{position:absolute;inset:0;display:none;flex-direction:column;
81
+ align-items:center;justify-content:center;gap:8px;
82
+ background:rgba(0,0,0,.55);backdrop-filter:blur(2px);border-radius:var(--radius)}
83
+ .ptz-overlay.open{display:flex}
84
+ .ptz-pad{display:grid;grid-template-columns:repeat(3,42px);grid-template-rows:repeat(3,42px);gap:3px}
85
+ .ptz-key{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.15);
86
+ border-radius:6px;color:#fff;font-size:16px;display:flex;align-items:center;
87
+ justify-content:center;cursor:pointer;user-select:none;touch-action:none;
88
+ -webkit-touch-callout:none;-webkit-user-select:none;
89
+ transition:background .1s,transform .1s}
90
+ .ptz-key:active,.ptz-key.held{background:rgba(37,99,235,.6);border-color:var(--accent);transform:scale(.93)}
91
+ .ptz-key.empty{background:none;border-color:transparent;cursor:default;pointer-events:none}
92
+ .ptz-zoom{display:flex;gap:6px}
93
+ .ptz-zoom .ptz-key{width:60px;height:36px;font-size:14px}
94
+ .ptz-close{position:absolute;top:8px;right:8px;width:26px;height:26px;border-radius:50%;
95
+ background:rgba(0,0,0,.6);border:1px solid rgba(255,255,255,.2);color:#fff;
96
+ font-size:14px;display:flex;align-items:center;justify-content:center;cursor:pointer}
97
+
98
+ /* Empty state */
99
+ .empty-state{display:flex;align-items:center;justify-content:center;color:var(--muted);
100
+ font-size:14px;height:100%;flex-direction:column;gap:8px}
101
+ .empty-state svg{opacity:.3}
102
+
103
+ /* ── Recordings view ── */
104
+ #view-rec{background:var(--s1);overflow:hidden}
105
+ #rec-filter-bar{flex-shrink:0;padding:10px 12px;background:var(--s1);
106
+ border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-wrap:wrap}
107
+ #rec-search{background:var(--s2);border:1px solid var(--border2);color:var(--text);
108
+ padding:7px 12px;border-radius:var(--radius);font-size:13px;width:180px;outline:none;
109
+ transition:border-color .15s}
110
+ #rec-search:focus{border-color:var(--accent)}
111
+ #rec-cam-sel{background:var(--s2);border:1px solid var(--border2);color:var(--text);
112
+ padding:7px 10px;border-radius:var(--radius);font-size:13px;outline:none;cursor:pointer}
113
+ #rec-tags{display:flex;gap:5px;flex-wrap:wrap}
114
+ .tag-chip{background:var(--s2);border:1px solid var(--border2);border-radius:99px;
115
+ padding:4px 11px;font-size:12px;cursor:pointer;color:var(--muted);user-select:none;transition:all .15s}
116
+ .tag-chip:hover{border-color:var(--border);color:var(--text)}
117
+ .tag-chip.on{background:rgba(37,99,235,.15);border-color:var(--accent);color:var(--accent-l)}
118
+ #rec-toolbar{display:flex;align-items:center;gap:6px;margin-left:auto;flex-shrink:0}
119
+ #rec-count{font-size:12px;color:var(--muted);white-space:nowrap}
120
+ .rec-view-btn{background:var(--s2);border:1px solid var(--border2);color:var(--muted);
121
+ width:28px;height:28px;border-radius:var(--radius);cursor:pointer;display:flex;align-items:center;
122
+ justify-content:center;transition:all .15s;flex-shrink:0}
123
+ .rec-view-btn:hover{color:var(--text);border-color:var(--border)}
124
+ .rec-view-btn.active{color:var(--accent-l);background:rgba(37,99,235,.12);border-color:var(--accent)}
125
+
126
+ /* ── Timeline ── */
127
+ .tl-wrap{display:flex;flex-direction:column;gap:0;padding-bottom:4px}
128
+ .tl-zoom{display:flex;align-items:center;gap:8px;margin-bottom:10px}
129
+ .tl-zoom-btn{width:26px;height:26px;border-radius:6px;background:var(--s2);border:1px solid var(--border2);
130
+ color:var(--text);font-size:18px;line-height:1;cursor:pointer;display:flex;align-items:center;
131
+ justify-content:center;transition:all .15s}
132
+ .tl-zoom-btn:hover:not(:disabled){background:var(--s3);border-color:var(--border)}
133
+ .tl-zoom-btn:disabled{opacity:.3;cursor:default}
134
+ .tl-zoom-lbl{font-size:12px;color:var(--muted);min-width:34px;text-align:center}
135
+ .tl-scroll{overflow-x:auto;overflow-y:visible;padding-bottom:28px;margin-bottom:12px}
136
+ .tl-scroll::-webkit-scrollbar{height:4px}
137
+ .tl-scroll::-webkit-scrollbar-track{background:transparent}
138
+ .tl-scroll::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
139
+ .tl-inner{min-width:100%;display:flex;flex-direction:column;gap:14px}
140
+ .tl-day-label{position:sticky;left:0;z-index:2;background:var(--s1);padding-right:10px;
141
+ font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;
142
+ letter-spacing:.05em;margin-bottom:6px}
143
+ .tl-bar{position:relative;height:28px;background:var(--s2);border:1px solid var(--border);
144
+ border-radius:4px;overflow:visible}
145
+ .tl-tick{position:absolute;top:30px;font-size:10px;color:var(--muted2);transform:translateX(-50%);
146
+ pointer-events:none;white-space:nowrap}
147
+ .tl-tick::before{content:'';position:absolute;top:-30px;left:50%;width:1px;height:28px;
148
+ background:var(--border);opacity:.5}
149
+ .tl-block{position:absolute;top:2px;height:calc(100% - 4px);min-width:4px;
150
+ background:var(--accent);border-radius:3px;cursor:pointer;opacity:.85;
151
+ transition:opacity .15s,filter .15s}
152
+ .tl-block:hover{opacity:1;filter:brightness(1.2)}
153
+ .tl-block[data-tag="Motion"]{background:var(--yellow)}
154
+ .tl-block[data-tag="Alert"]{background:var(--orange)}
155
+ /* ── Video nav ── */
156
+ #vid-nav{display:flex;align-items:center;justify-content:space-between;margin-top:8px;gap:8px}
157
+ #vid-nav button{background:var(--s3);border:1px solid var(--border2);color:var(--text);
158
+ padding:5px 14px;border-radius:var(--radius);cursor:pointer;font-size:13px;transition:all .15s;
159
+ white-space:nowrap}
160
+ #vid-nav button:hover:not(:disabled){background:rgba(37,99,235,.2);border-color:var(--accent);color:var(--accent-l)}
161
+ #vid-nav button:disabled{opacity:.3;cursor:default}
162
+ #vid-pos{font-size:12px;color:var(--muted);white-space:nowrap}
163
+
164
+ #rec-scroll{flex:1;overflow-y:auto;padding:10px 12px}
165
+ #rec-scroll::-webkit-scrollbar{width:5px}
166
+ #rec-scroll::-webkit-scrollbar-track{background:transparent}
167
+ #rec-scroll::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
168
+ .rec-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(175px,1fr));gap:8px}
169
+ .rec-tile{background:var(--s2);border:1px solid var(--border);border-radius:var(--radius);
170
+ overflow:hidden;cursor:pointer;transition:transform .15s,box-shadow .15s,border-color .15s}
171
+ .rec-tile:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,.45);border-color:var(--border2)}
172
+ .rec-tile:active{transform:translateY(0)}
173
+ .rec-thumb-wrap{position:relative;aspect-ratio:16/9;overflow:hidden;background:#000}
174
+ .rec-thumb{width:100%;height:100%;object-fit:cover;display:block;transition:transform .3s}
175
+ .rec-tile:hover .rec-thumb{transform:scale(1.04)}
176
+ .rec-play-icon{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
177
+ opacity:0;background:rgba(0,0,0,.35);transition:opacity .15s}
178
+ .rec-tile:hover .rec-play-icon{opacity:1}
179
+ .rec-play-icon svg{filter:drop-shadow(0 2px 6px #000)}
180
+ .rec-badges{position:absolute;top:var(--rec-badge-top,5px);bottom:var(--rec-badge-bot,auto);right:var(--rec-badge-right,5px);left:var(--rec-badge-left,auto);display:flex;flex-direction:column;align-items:var(--rec-badge-align,flex-end);gap:3px}
181
+ .badge{background:rgba(0,0,0,.65);border-radius:4px;padding:2px 6px;font-size:10px;
182
+ font-weight:500;letter-spacing:.3px;border:1px solid rgba(255,255,255,.1)}
183
+ .badge.tag{color:var(--accent-l);border-color:rgba(37,99,235,.35)}
184
+ .rec-info{padding:8px 10px 9px}
185
+ .rec-date{font-size:12px;font-weight:600;color:var(--text)}
186
+ .rec-meta{font-size:11px;color:var(--muted);margin-top:2px}
187
+ .rec-empty{padding:40px 20px;text-align:center;color:var(--muted);font-size:13px}
188
+
189
+ /* ── Persistent video panel ── */
190
+ #vid-panel{position:fixed;inset:0;z-index:300;display:none;align-items:center;justify-content:center;
191
+ background:rgba(0,0,0,.88)}
192
+ #vid-panel.open{display:flex}
193
+ #vid-box{position:relative;background:var(--s2);border:1px solid var(--border2);border-radius:12px;
194
+ padding:14px;width:min(1000px,97vw);box-shadow:0 24px 64px rgba(0,0,0,.7)}
195
+ #vid-hdr{margin-bottom:10px;padding-right:36px}
196
+ #vid-title{font-size:13px;font-weight:600}
197
+ #vid-cam{font-size:11px;color:var(--muted);margin-top:1px}
198
+ #vid-close{position:absolute;top:10px;right:10px;width:30px;height:30px;border-radius:50%;
199
+ background:var(--s3);border:1px solid var(--border2);
200
+ color:var(--text);font-size:16px;display:flex;align-items:center;justify-content:center;cursor:pointer}
201
+ #vid-close:hover{background:var(--red);border-color:var(--red)}
202
+ #vid-el{width:100%;max-height:74dvh;background:#000;border-radius:6px;display:block;outline:none}
203
+ #vid-meta{margin-top:8px;font-size:11px;color:var(--muted);display:flex;align-items:center;gap:12px}
204
+ #vid-dl{margin-left:auto;color:var(--accent-l);text-decoration:none;display:flex;align-items:center;gap:4px;padding:3px 8px;border-radius:4px;border:1px solid rgba(37,99,235,.35);transition:background .15s}
205
+ #vid-dl:hover{background:rgba(37,99,235,.15)}
206
+
207
+ /* ── Fullscreen cam ── */
208
+ #fs-panel{position:fixed;inset:0;z-index:250;display:none;background:#000;flex-direction:column}
209
+ #fs-panel.open{display:flex}
210
+ #fs-hdr{height:44px;background:rgba(0,0,0,.7);display:flex;align-items:center;
211
+ padding:0 12px;gap:12px;flex-shrink:0}
212
+ #fs-name{font-size:14px;font-weight:600}
213
+ #fs-btns{display:flex;gap:6px;align-items:center;margin-left:auto}
214
+ #fs-btns .tile-btn{opacity:1;width:32px;height:32px;font-size:16px}
215
+ #fs-close{margin-left:8px;width:30px;height:30px;border-radius:50%;background:rgba(255,255,255,.1);
216
+ border:none;color:#fff;font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer}
217
+ #fs-close:hover{background:rgba(255,255,255,.2)}
218
+ #fs-body{flex:1;position:relative;min-height:0;display:flex}
219
+ #fs-img{flex:1;width:100%;object-fit:contain;display:block;min-height:0}
220
+ #fs-body .ptz-overlay{position:absolute;inset:0}
221
+
222
+ /* ── Status bar ── */
223
+ #sbar{height:24px;background:rgba(8,11,15,.97);border-top:1px solid var(--border);
224
+ display:flex;align-items:center;padding:0 14px;gap:16px;flex-shrink:0}
225
+ #sbar-msg{font-size:11px;color:var(--muted);flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
226
+ #sbar-msg.err{color:var(--red)}
227
+
228
+ /* ── Info banner ── */
229
+ #info-banner{display:none;background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.3);
230
+ color:var(--orange);padding:9px 14px;margin:var(--gap);margin-bottom:0;border-radius:var(--radius);
231
+ font-size:12px;line-height:1.6;flex-shrink:0}
232
+ #info-banner.visible{display:block}
233
+ #info-banner button{margin-top:6px;background:var(--orange);color:#000;border:none;padding:3px 10px;
234
+ border-radius:4px;cursor:pointer;font-size:12px}
235
+
236
+ /* ── Scrollbar global ── */
237
+ ::-webkit-scrollbar{width:5px;height:5px}
238
+ ::-webkit-scrollbar-track{background:transparent}
239
+ ::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
240
+
241
+ /* ── WebRTC video in tiles ── */
242
+ .cam-tile video{width:100%;height:100%;object-fit:contain;display:block;background:#000}
243
+
244
+ </style>
245
+ </head>
246
+ <body>
247
+ <div id="app">
248
+
249
+ <div id="hdr">
250
+ <div id="nav">
251
+ <button class="nav-btn active" onclick="showView('live')">
252
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
253
+ Live
254
+ </button>
255
+ <button class="nav-btn" onclick="showView('rec')">
256
+ <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
+ Aufnahmen
258
+ </button>
259
+ </div>
260
+ <div class="hdr-r">
261
+ <span id="conn-wrap"><div id="conn-dot"></div></span>
262
+ </div>
263
+ </div>
264
+
265
+ <main>
266
+ <!-- Live -->
267
+ <div id="view-live" class="view active">
268
+ <div id="info-banner"></div>
269
+ <div id="chip-bar"></div>
270
+ <div id="cam-grid"></div>
271
+ </div>
272
+
273
+ <!-- Recordings -->
274
+ <div id="view-rec" class="view">
275
+ <div id="rec-filter-bar">
276
+ <input id="rec-search" type="search" placeholder="&#128269; Suchen…" oninput="filterRec()">
277
+ <select id="rec-cam-sel" onchange="loadAndFilterRec()">
278
+ <option value="">Kamera wählen…</option>
279
+ </select>
280
+ <div id="rec-tags"></div>
281
+ <div id="rec-toolbar">
282
+ <span id="rec-count"></span>
283
+ <button id="rec-view-grid" class="rec-view-btn active" title="Rasteransicht" onclick="setRecView('grid')">
284
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
285
+ <rect x="0" y="0" width="6" height="6" rx="1"/><rect x="8" y="0" width="6" height="6" rx="1"/>
286
+ <rect x="0" y="8" width="6" height="6" rx="1"/><rect x="8" y="8" width="6" height="6" rx="1"/>
287
+ </svg>
288
+ </button>
289
+ <button id="rec-view-tl" class="rec-view-btn" title="Timeline" onclick="setRecView('timeline')">
290
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5">
291
+ <line x1="0" y1="4" x2="14" y2="4"/><rect x="2" y="2" width="4" height="4" rx="1" fill="currentColor"/>
292
+ <line x1="0" y1="10" x2="14" y2="10"/><rect x="7" y="8" width="5" height="4" rx="1" fill="currentColor"/>
293
+ </svg>
294
+ </button>
295
+ </div>
296
+ </div>
297
+ <div id="rec-scroll">
298
+ <div id="rec-grid" class="rec-grid"></div>
299
+ </div>
300
+ </div>
301
+ </main>
302
+
303
+ <div id="sbar"><span id="sbar-msg">Lade…</span></div>
304
+ </div><!-- #app -->
305
+
306
+ <!-- Video panel (persistent overlay, survives tab switch) -->
307
+ <div id="vid-panel">
308
+ <div id="vid-box">
309
+ <button id="vid-close" onclick="closeVideo()">&#10005;</button>
310
+ <div id="vid-hdr">
311
+ <div id="vid-title"></div>
312
+ <div id="vid-cam"></div>
313
+ </div>
314
+ <video id="vid-el" controls playsinline preload="auto"></video>
315
+ <div id="vid-nav">
316
+ <button id="vid-prev" onclick="playRecIdx(recPlayIdx - 1)">&#8249; Vorherige</button>
317
+ <span id="vid-pos"></span>
318
+ <button id="vid-next" onclick="playRecIdx(recPlayIdx + 1)">Nächste &#8250;</button>
319
+ </div>
320
+ <div id="vid-meta">
321
+ <span id="vid-meta-txt"></span>
322
+ <a id="vid-dl" download>
323
+ <svg width="12" height="12" viewBox="0 0 10 11" fill="currentColor"><path d="M4.5 0h1v6.3l2.1-2.1.7.7L5 8.2 1.7 4.9l.7-.7 2.1 2.1V0zM0 10h10v1H0v-1z"/></svg>
324
+ Download
325
+ </a>
326
+ </div>
327
+ </div>
328
+ </div>
329
+
330
+ <!-- Fullscreen live cam -->
331
+ <div id="fs-panel">
332
+ <div id="fs-hdr">
333
+ <span id="fs-name"></span>
334
+ <div id="fs-btns"></div>
335
+ <button id="fs-close" onclick="closeFs()">&#10005;</button>
336
+ </div>
337
+ <div id="fs-body">
338
+ <img id="fs-img" src="" alt="">
339
+ <video id="fs-vid" autoplay muted playsinline style="display:none;flex:1;width:100%;object-fit:contain;min-height:0;background:#000"></video>
340
+ </div>
341
+ </div>
342
+
343
+ <script>
344
+ 'use strict';
345
+ /* ─────────────────────────── State ─────────────────────────── */
346
+ let INST = 'agent-dvr.0', BASE = '', cameras = {}, hasPtz = false;
347
+ let hidden = new Set(JSON.parse(localStorage.getItem('advr_hidden') || '[]'));
348
+ let recAllItems = [];
349
+ let recFilters = { tag: '' };
350
+ let recViewMode = localStorage.getItem('advr_rec_view') || 'grid';
351
+ let recPlayList = [], recPlayIdx = -1;
352
+ const TL_ZOOMS = [1, 2, 4, 8, 24, 48];
353
+ let tlZoomLevel = parseInt(localStorage.getItem('advr_tl_zoom') || '1', 10);
354
+ if (!TL_ZOOMS.includes(tlZoomLevel)) tlZoomLevel = 1;
355
+ let ptzHeld = null;
356
+ let refreshTimer = null;
357
+ let go2rtcMap = {}; // populated from CFG.go2rtcMapping after loadConfig()
358
+
359
+ /* ─────────────────────────── Dashboard config ──────────────── */
360
+ let CFG = {
361
+ dashDefaultView: 'live',
362
+ dashShowOffline: true,
363
+ dashGridCols: 0,
364
+ dashBtnsVisible: false,
365
+ dashRefreshSec: 60,
366
+ dashStreamReconnect: true,
367
+ dashColorBg: '',
368
+ dashColorSurface: '',
369
+ dashColorAccent: '',
370
+ dashColorText: '',
371
+ dashColorBorder: '',
372
+ dashColorOnline: '',
373
+ dashColorOffline: '',
374
+ dashTagPosition: 'top-right',
375
+ go2rtcEnabled: false,
376
+ go2rtcUrl: '',
377
+ go2rtcMapping: [],
378
+ };
379
+ const DASH_CSS_VARS = {
380
+ dashColorBg: '--bg',
381
+ dashColorSurface: '--s1',
382
+ dashColorAccent: '--accent',
383
+ dashColorText: '--text',
384
+ dashColorBorder: '--border',
385
+ dashColorOnline: '--green',
386
+ dashColorOffline: '--red',
387
+ };
388
+ async function loadConfig() {
389
+ try {
390
+ const r = await fetch('/agent-dvr/api/config', { signal: AbortSignal.timeout(5000) });
391
+ if (r.ok) Object.assign(CFG, await r.json());
392
+ } catch (_) {}
393
+ document.documentElement.style.setProperty('--btn-base-opacity', CFG.dashBtnsVisible ? '1' : '0');
394
+ go2rtcMap = {};
395
+ (Array.isArray(CFG.go2rtcMapping) ? CFG.go2rtcMapping : []).forEach(row => {
396
+ if (row.camKey && row.stream) go2rtcMap[row.camKey] = row.stream;
397
+ });
398
+ for (const [key, prop] of Object.entries(DASH_CSS_VARS)) {
399
+ if (CFG[key]) document.documentElement.style.setProperty(prop, CFG[key]);
400
+ }
401
+ const [bv, bh] = (CFG.dashTagPosition || 'top-right').split('-');
402
+ document.documentElement.style.setProperty('--rec-badge-top', bv === 'top' ? '5px' : 'auto');
403
+ document.documentElement.style.setProperty('--rec-badge-bot', bv === 'bottom' ? '5px' : 'auto');
404
+ document.documentElement.style.setProperty('--rec-badge-right', bh === 'right' ? '5px' : 'auto');
405
+ document.documentElement.style.setProperty('--rec-badge-left', bh === 'left' ? '5px' : 'auto');
406
+ document.documentElement.style.setProperty('--rec-badge-align', bh === 'right' ? 'flex-end' : 'flex-start');
407
+ }
408
+
409
+ /* ─────────────────────────── Init ──────────────────────────── */
410
+ async function loadData() {
411
+ sbar('Lade Kameras…');
412
+ try {
413
+ const r = await fetch('/agent-dvr/api/cameras', { signal: AbortSignal.timeout(8000) });
414
+ if (!r.ok) throw new Error('HTTP ' + r.status);
415
+ const d = await r.json();
416
+ if (d.error) throw new Error(d.error);
417
+ INST = d.instance || 'agent-dvr.0';
418
+ BASE = d.base || '';
419
+ cameras = d.cameras || {};
420
+ hasPtz = Object.values(cameras).some(c => c.hasPtz);
421
+
422
+ const n = Object.keys(cameras).length;
423
+ if (!n) {
424
+ showBanner('<strong>Keine Kameras</strong><br>AgentDVR erreichbar? IP/Port in Adapter-Config prüfen.', true);
425
+ sbar('Keine Kameras · ' + INST, true);
426
+ return;
427
+ }
428
+ hideBanner();
429
+ sbar(n + ' Kamera(s) · ' + INST + (BASE ? ' · ' + BASE : ''));
430
+ syncChips();
431
+ syncGrid();
432
+ buildRecCamSel();
433
+ trySocket();
434
+ if (!refreshTimer) showView(CFG.dashDefaultView || 'live'); // only on first load
435
+ // Periodic refresh
436
+ if (refreshTimer) clearInterval(refreshTimer);
437
+ if (CFG.dashRefreshSec >= 10) {
438
+ refreshTimer = setInterval(() => loadData(), CFG.dashRefreshSec * 1000);
439
+ }
440
+ } catch (e) {
441
+ showBanner('<strong>Ladefehler</strong><br>' + esc(e.message), true);
442
+ sbar('Fehler: ' + e.message, true);
443
+ }
444
+ }
445
+
446
+ /* ─────────────────────────── Socket (live dot updates) ─────── */
447
+ function trySocket() {
448
+ if (typeof io === 'undefined') return;
449
+ try {
450
+ const s = io({ transports: ['websocket','polling'], reconnection: true });
451
+ s.on('connect', () => {
452
+ connDot(true);
453
+ ['data.online','status.recording','status.detected','status.alerted'].forEach(p =>
454
+ s.emit('subscribe', INST + '.cam_*.' + p, () => {}));
455
+ });
456
+ s.on('disconnect', () => connDot(false));
457
+ s.on('stateChange', (id, state) => {
458
+ if (!state || !id.startsWith(INST + '.')) return;
459
+ const rel = id.slice(INST.length + 1);
460
+ const ck = rel.split('.')[0];
461
+ if (!cameras[ck]) return;
462
+ if (rel.endsWith('.data.online')) {
463
+ cameras[ck].online = !!state.val;
464
+ const d = document.querySelector('.cam-tile[data-cam="' + ck + '"] .cam-dot');
465
+ if (d) d.className = 'cam-dot ' + (state.val ? 'on' : 'off');
466
+ } else if (rel.endsWith('.status.recording')) {
467
+ cameras[ck].recording = !!state.val;
468
+ const t = document.querySelector('.cam-tile[data-cam="' + ck + '"]');
469
+ if (t) { const rb = t.querySelector('.rec-btn'); if (rb) setTileRecording(t, rb, !!state.val); }
470
+ } else if (rel.endsWith('.status.detected')) {
471
+ cameras[ck].detected = !!state.val;
472
+ setTileStatus(ck, 'detected', !!state.val);
473
+ } else if (rel.endsWith('.status.alerted')) {
474
+ cameras[ck].alerted = !!state.val;
475
+ setTileStatus(ck, 'alerted', !!state.val);
476
+ }
477
+ });
478
+ } catch (_) {}
479
+ }
480
+
481
+ /* ─────────────────────────── Views ─────────────────────────── */
482
+ function showView(name) {
483
+ document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
484
+ document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
485
+ document.getElementById('view-' + name).classList.add('active');
486
+ [...document.querySelectorAll('.nav-btn')].find(b => b.textContent.trim().toLowerCase().includes(name === 'live' ? 'live' : 'aufn'))?.classList.add('active');
487
+ if (name === 'live') updateLayout();
488
+ }
489
+
490
+ /* ─────────────────────────── Chips ─────────────────────────── */
491
+ function syncChips() {
492
+ const bar = document.getElementById('chip-bar');
493
+ bar.innerHTML = '';
494
+ if (!Object.keys(cameras).length) return;
495
+ addChip(bar, 'Alle', hidden.size === 0, () => { hidden.clear(); save(); syncChips(); syncGrid(); });
496
+ Object.entries(cameras).forEach(([k, cam]) => {
497
+ addChip(bar, cam.name, !hidden.has(k), () => {
498
+ hidden.has(k) ? hidden.delete(k) : hidden.add(k);
499
+ save(); syncChips(); syncGrid();
500
+ });
501
+ });
502
+ }
503
+ function addChip(p, label, on, fn) {
504
+ const c = document.createElement('span');
505
+ c.className = 'chip' + (on ? ' on' : '');
506
+ c.textContent = label; c.onclick = fn;
507
+ p.appendChild(c);
508
+ }
509
+ function save() { localStorage.setItem('advr_hidden', JSON.stringify([...hidden])); }
510
+
511
+ /* ─────────────────────────── Camera grid (smart update) ────── */
512
+ function syncGrid() {
513
+ const grid = document.getElementById('cam-grid');
514
+ const want = Object.keys(cameras).filter(k => {
515
+ if (hidden.has(k)) return false;
516
+ if (!CFG.dashShowOffline && !cameras[k].online) return false;
517
+ return true;
518
+ });
519
+
520
+ // Remove tiles no longer needed (without touching others → streams keep running)
521
+ [...grid.querySelectorAll('.cam-tile')].forEach(t => {
522
+ if (!want.includes(t.dataset.cam)) t.remove();
523
+ });
524
+
525
+ // Add missing tiles, update recording state on existing ones
526
+ const have = new Set([...grid.querySelectorAll('.cam-tile')].map(t => t.dataset.cam));
527
+ want.forEach(k => {
528
+ if (!have.has(k)) {
529
+ grid.appendChild(makeTile(k, cameras[k]));
530
+ } else {
531
+ const t = grid.querySelector('.cam-tile[data-cam="' + k + '"]');
532
+ if (t) { const rb = t.querySelector('.rec-btn'); if (rb) setTileRecording(t, rb, !!cameras[k].recording); }
533
+ }
534
+ });
535
+
536
+ if (!grid.querySelector('.cam-tile')) {
537
+ grid.innerHTML = `<div class="empty-state">
538
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
539
+ <circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/>
540
+ <line x1="2" y1="2" x2="22" y2="22" stroke-width="1.5"/>
541
+ </svg>Keine Kameras sichtbar</div>`;
542
+ }
543
+ updateLayout();
544
+ }
545
+
546
+ function makeTile(key, cam) {
547
+ const tile = document.createElement('div');
548
+ tile.className = 'cam-tile'; tile.dataset.cam = key;
549
+
550
+ // Decide stream type: WebRTC (go2rtc) or MJPEG/snapshot
551
+ const g2mStream = typeof go2rtcMap[key] === 'string' ? go2rtcMap[key] : '';
552
+ const useWrtc = CFG.go2rtcEnabled && CFG.go2rtcUrl && !!g2mStream;
553
+
554
+ let mediaEl;
555
+ if (useWrtc) {
556
+ const vid = document.createElement('video');
557
+ vid.autoplay = true; vid.muted = true; vid.playsInline = true;
558
+ vid.dataset.stream = g2mStream;
559
+ connectWebRTC(vid, CFG.go2rtcUrl, g2mStream);
560
+ mediaEl = vid;
561
+ } else {
562
+ const img = document.createElement('img'); img.alt = cam.name;
563
+ if (cam.mjpegUrl) {
564
+ img.dataset.mjpeg = cam.mjpegUrl;
565
+ img.src = cam.mjpegUrl;
566
+ img.onerror = () => {
567
+ if (img.dataset.hiding) return;
568
+ if (cam.snapshotUrl && img.src !== cam.snapshotUrl) {
569
+ img.src = cam.snapshotUrl;
570
+ } else if (CFG.dashStreamReconnect && cam.mjpegUrl && img.dataset.retry !== '1') {
571
+ img.dataset.retry = '1';
572
+ setTimeout(() => { img.dataset.retry = '0'; img.src = cam.mjpegUrl; }, 8000);
573
+ }
574
+ };
575
+ } else if (cam.snapshotUrl) { img.src = cam.snapshotUrl; }
576
+ mediaEl = img;
577
+ }
578
+
579
+ const bottom = document.createElement('div'); bottom.className = 'cam-bottom';
580
+ const name = document.createElement('div'); name.className = 'cam-name'; name.textContent = cam.name;
581
+ const dot = document.createElement('div'); dot.className = 'cam-dot ' + (cam.online ? 'on' : 'off');
582
+ bottom.append(name, dot);
583
+
584
+ // Click on media → fullscreen (not on buttons)
585
+ mediaEl.onclick = () => openFs(cam, key);
586
+ bottom.onclick = () => openFs(cam, key);
587
+ tile.onclick = e => { if (e.target === tile) openFs(cam, key); };
588
+
589
+ // Button container (rec + optional PTZ)
590
+ const btnBox = document.createElement('div'); btnBox.className = 'tile-btns';
591
+
592
+ const recBtn = document.createElement('div');
593
+ recBtn.className = 'tile-btn rec-btn'; recBtn.title = 'Aufnahme starten';
594
+ recBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 12 12"><circle cx="6" cy="6" r="4" fill="currentColor"/></svg>`;
595
+ recBtn.addEventListener('click', e => { e.stopPropagation(); toggleRec(key, recBtn, tile); });
596
+ btnBox.appendChild(recBtn);
597
+
598
+ if (cam.hasPtz) {
599
+ const ptzToggle = document.createElement('div');
600
+ ptzToggle.className = 'tile-btn ptz-btn'; ptzToggle.title = 'PTZ';
601
+ ptzToggle.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
602
+ <circle cx="12" cy="12" r="3"/><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
603
+ </svg>`;
604
+ ptzToggle.addEventListener('click', e => { e.stopPropagation(); togglePtz(tile, key); });
605
+ btnBox.appendChild(ptzToggle);
606
+ tile.appendChild(makePtzOverlay(key));
607
+ }
608
+
609
+ tile.appendChild(btnBox);
610
+ if (cam.detected) tile.classList.add('detected');
611
+ if (cam.alerted) tile.classList.add('alerted');
612
+ if (cam.recording) setTileRecording(tile, recBtn, true);
613
+
614
+ tile.append(mediaEl, bottom);
615
+ return tile;
616
+ }
617
+
618
+ /* ─────────────────────────── PTZ ────────────────────────────── */
619
+ function makePtzOverlay(camKey) {
620
+ const ov = document.createElement('div'); ov.className = 'ptz-overlay';
621
+
622
+ // d-pad: 3×3 grid, empty corners except diagonals
623
+ const pad = document.createElement('div'); pad.className = 'ptz-pad';
624
+ const LAYOUT = [
625
+ ['upLeft','up','upRight'],
626
+ ['left','center','right'],
627
+ ['downLeft','down','downRight'],
628
+ ];
629
+ const ICONS = {
630
+ upLeft:'↖',up:'↑',upRight:'↗',
631
+ left:'←',center:'⊙',right:'→',
632
+ downLeft:'↙',down:'↓',downRight:'↘',
633
+ };
634
+ const HOLD_DIRS = new Set(['left','upLeft','up','upRight','right','downRight','down','downLeft','zoomIn','zoomOut']);
635
+
636
+ LAYOUT.forEach(row => {
637
+ row.forEach(dir => {
638
+ const btn = document.createElement('div');
639
+ btn.className = 'ptz-key'; btn.textContent = ICONS[dir]; btn.dataset.dir = dir;
640
+ btn.addEventListener('contextmenu', e => e.preventDefault());
641
+ btn.addEventListener('pointerdown', e => {
642
+ e.stopPropagation(); e.preventDefault();
643
+ btn.setPointerCapture(e.pointerId);
644
+ btn.classList.add('held');
645
+ const isHold = HOLD_DIRS.has(dir);
646
+ if (isHold) { ptzHeld = { camKey, dir }; ptzSend(camKey, dir, true); }
647
+ else { ptzSend(camKey, dir, true); }
648
+ });
649
+ btn.addEventListener('pointerup', e => {
650
+ e.stopPropagation();
651
+ btn.classList.remove('held');
652
+ if (ptzHeld && ptzHeld.camKey === camKey && ptzHeld.dir === dir) {
653
+ ptzSend(camKey, dir, false); ptzHeld = null;
654
+ }
655
+ });
656
+ btn.addEventListener('pointercancel', () => {
657
+ btn.classList.remove('held');
658
+ if (ptzHeld && ptzHeld.camKey === camKey) { ptzSend(camKey, ptzHeld.dir, false); ptzHeld = null; }
659
+ });
660
+ pad.appendChild(btn);
661
+ });
662
+ });
663
+
664
+ // Zoom row
665
+ const zoom = document.createElement('div'); zoom.className = 'ptz-zoom';
666
+ [['zoomIn','🔍+'],['zoomOut','🔍−']].forEach(([dir, icon]) => {
667
+ const btn = document.createElement('div');
668
+ btn.className = 'ptz-key'; btn.textContent = icon; btn.dataset.dir = dir;
669
+ btn.addEventListener('contextmenu', e => e.preventDefault());
670
+ btn.addEventListener('pointerdown', e => {
671
+ e.stopPropagation(); e.preventDefault(); btn.setPointerCapture(e.pointerId);
672
+ btn.classList.add('held'); ptzHeld = { camKey, dir }; ptzSend(camKey, dir, true);
673
+ });
674
+ btn.addEventListener('pointerup', e => {
675
+ e.stopPropagation(); btn.classList.remove('held');
676
+ if (ptzHeld && ptzHeld.dir === dir) { ptzSend(camKey, dir, false); ptzHeld = null; }
677
+ });
678
+ btn.addEventListener('pointercancel', () => {
679
+ btn.classList.remove('held');
680
+ if (ptzHeld) { ptzSend(camKey, ptzHeld.dir, false); ptzHeld = null; }
681
+ });
682
+ zoom.appendChild(btn);
683
+ });
684
+
685
+ const closeBtn = document.createElement('div'); closeBtn.className = 'ptz-close';
686
+ closeBtn.textContent = '✕';
687
+ closeBtn.addEventListener('click', e => { e.stopPropagation(); ov.classList.remove('open'); });
688
+
689
+ ov.append(closeBtn, pad, zoom);
690
+ return ov;
691
+ }
692
+
693
+ function togglePtz(tile, camKey) {
694
+ const ov = tile.querySelector('.ptz-overlay');
695
+ if (!ov) return;
696
+ const btn = tile.querySelector('.ptz-btn');
697
+ const open = !ov.classList.contains('open');
698
+ ov.classList.toggle('open', open);
699
+ btn.classList.toggle('active', open);
700
+ }
701
+
702
+ async function ptzSend(camKey, dir, val) {
703
+ try {
704
+ await fetch(`/agent-dvr/api/ptz?camKey=${encodeURIComponent(camKey)}&dir=${dir}&val=${val?1:0}`);
705
+ } catch (_) {}
706
+ }
707
+
708
+ // Safety: release any held PTZ if pointer leaves document
709
+ document.addEventListener('pointerup', () => {
710
+ if (ptzHeld) { ptzSend(ptzHeld.camKey, ptzHeld.dir, false); ptzHeld = null; }
711
+ });
712
+
713
+ /* ─────────────────────────── Grid layout ───────────────────── */
714
+ let layoutObserver = null;
715
+ function calcLayout(n, W, H, gap) {
716
+ if (CFG.dashGridCols > 0) {
717
+ const cols = Math.min(CFG.dashGridCols, n);
718
+ return { cols, rows: Math.ceil(n / cols) };
719
+ }
720
+ let bestCols = 1, best = -1;
721
+ for (let c = 1; c <= n; c++) {
722
+ const r = Math.ceil(n / c);
723
+ const tw = (W - gap * (c - 1)) / c;
724
+ const th = (H - gap * (r - 1)) / r;
725
+ const cw = Math.min(tw, th * 16 / 9);
726
+ const score = cw * cw * (9 / 16);
727
+ if (score > best) { best = score; bestCols = c; }
728
+ }
729
+ return { cols: bestCols, rows: Math.ceil(n / bestCols) };
730
+ }
731
+ function updateLayout() {
732
+ const grid = document.getElementById('cam-grid');
733
+ const tiles = grid.querySelectorAll('.cam-tile').length;
734
+ if (!tiles) return;
735
+ const W = grid.clientWidth, H = grid.clientHeight;
736
+ if (W < 10 || H < 10) return;
737
+ const { cols, rows } = calcLayout(tiles, W, H, 6);
738
+ grid.style.gridTemplateColumns = `repeat(${cols},1fr)`;
739
+ grid.style.gridTemplateRows = `repeat(${rows},1fr)`;
740
+ }
741
+ function ensureLayoutObserver() {
742
+ if (layoutObserver) return;
743
+ layoutObserver = new ResizeObserver(updateLayout);
744
+ layoutObserver.observe(document.getElementById('cam-grid'));
745
+ }
746
+
747
+ /* ─────────────────────────── Motion / Alert status ─────────── */
748
+ function setTileStatus(ck, cls, on) {
749
+ const t = document.querySelector('.cam-tile[data-cam="' + ck + '"]');
750
+ if (t) t.classList.toggle(cls, on);
751
+ const fs = document.getElementById('fs-panel');
752
+ if (fs.dataset.cam === ck) fs.classList.toggle(cls, on);
753
+ }
754
+
755
+ /* ─────────────────────────── Recording ─────────────────────── */
756
+ function setTileRecording(tile, btn, recording) {
757
+ tile.classList.toggle('recording', recording);
758
+ btn.classList.toggle('active', recording);
759
+ btn.title = recording ? 'Aufnahme stoppen' : 'Aufnahme starten';
760
+ const svgRec = `<svg width="12" height="12" viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8" rx="1" fill="currentColor"/></svg>`;
761
+ const svgStop = `<svg width="12" height="12" viewBox="0 0 12 12"><circle cx="6" cy="6" r="4" fill="currentColor"/></svg>`;
762
+ btn.innerHTML = recording ? svgRec : svgStop;
763
+ const camKey = tile.dataset.cam;
764
+ // Sync fs-panel red border + its rec button
765
+ const fs = document.getElementById('fs-panel');
766
+ if (fs.dataset.cam === camKey) {
767
+ fs.classList.toggle('recording', recording);
768
+ const fsBtn = fs.querySelector('#fs-btns .rec-btn');
769
+ if (fsBtn && fsBtn !== btn) { fsBtn.classList.toggle('active', recording); fsBtn.title = btn.title; fsBtn.innerHTML = btn.innerHTML; }
770
+ }
771
+ // Sync grid tile if we were called from fs-panel
772
+ if (tile === fs) {
773
+ const gridTile = document.querySelector('.cam-tile[data-cam="' + camKey + '"]');
774
+ if (gridTile) {
775
+ gridTile.classList.toggle('recording', recording);
776
+ const tBtn = gridTile.querySelector('.rec-btn');
777
+ if (tBtn) { tBtn.classList.toggle('active', recording); tBtn.title = btn.title; tBtn.innerHTML = btn.innerHTML; }
778
+ }
779
+ }
780
+ }
781
+ function toggleRec(key, btn, tile) {
782
+ const isRec = btn.classList.contains('active');
783
+ fetch('/agent-dvr/api/record?camKey=' + encodeURIComponent(key) + '&action=' + (isRec ? 'stop' : 'start'))
784
+ .then(r => { if (r.ok) setTileRecording(tile, btn, !isRec); })
785
+ .catch(() => {});
786
+ }
787
+
788
+ /* ─────────────────────────── Fullscreen ────────────────────── */
789
+ function openFs(cam, key) {
790
+ document.getElementById('fs-name').textContent = cam.name;
791
+ const fi = document.getElementById('fs-img');
792
+ const fv = document.getElementById('fs-vid');
793
+ const fs = document.getElementById('fs-panel');
794
+
795
+ // Stop any running fullscreen stream first
796
+ fi.src = '';
797
+ disconnectWebRTC(fv);
798
+
799
+ // Check if we should use WebRTC for this camera
800
+ const g2mStream = typeof go2rtcMap[key] === 'string' ? go2rtcMap[key] : '';
801
+ const useWrtc = CFG.go2rtcEnabled && CFG.go2rtcUrl && !!g2mStream;
802
+ if (useWrtc) {
803
+ fi.style.display = 'none';
804
+ fv.style.display = 'block';
805
+ connectWebRTC(fv, CFG.go2rtcUrl, g2mStream);
806
+ } else {
807
+ fi.style.display = 'block';
808
+ fv.style.display = 'none';
809
+ fi.src = cam.mjpegUrl || cam.snapshotUrl || '';
810
+ }
811
+ fs.dataset.cam = key || '';
812
+
813
+ // Determine current recording state from live tile (more up-to-date than cam object)
814
+ const gridTile = document.querySelector('.cam-tile[data-cam="' + key + '"]');
815
+ const isRec = gridTile ? gridTile.classList.contains('recording') : !!cam.recording;
816
+ fs.classList.toggle('recording', isRec);
817
+
818
+ // Build header buttons
819
+ const btnBox = document.getElementById('fs-btns');
820
+ btnBox.innerHTML = '';
821
+
822
+ const recBtn = document.createElement('div');
823
+ recBtn.className = 'tile-btn rec-btn';
824
+ recBtn.classList.toggle('active', isRec);
825
+ recBtn.title = isRec ? 'Aufnahme stoppen' : 'Aufnahme starten';
826
+ recBtn.innerHTML = isRec
827
+ ? `<svg width="12" height="12" viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8" rx="1" fill="currentColor"/></svg>`
828
+ : `<svg width="12" height="12" viewBox="0 0 12 12"><circle cx="6" cy="6" r="4" fill="currentColor"/></svg>`;
829
+ recBtn.addEventListener('click', e => { e.stopPropagation(); toggleRec(key, recBtn, fs); });
830
+ btnBox.appendChild(recBtn);
831
+
832
+ if (cam.hasPtz) {
833
+ const ptzBtn = document.createElement('div');
834
+ ptzBtn.className = 'tile-btn ptz-btn';
835
+ ptzBtn.title = 'PTZ';
836
+ ptzBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
837
+ <circle cx="12" cy="12" r="3"/><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
838
+ </svg>`;
839
+ btnBox.appendChild(ptzBtn);
840
+
841
+ const body = document.getElementById('fs-body');
842
+ const ov = makePtzOverlay(key);
843
+ body.appendChild(ov);
844
+ ptzBtn.addEventListener('click', e => {
845
+ e.stopPropagation();
846
+ const open = ov.classList.toggle('open');
847
+ ptzBtn.classList.toggle('active', open);
848
+ });
849
+ }
850
+
851
+ fs.classList.add('open');
852
+ }
853
+ function closeFs() {
854
+ const fi = document.getElementById('fs-img');
855
+ const fv = document.getElementById('fs-vid');
856
+ fi.src = ''; fi.style.display = 'block';
857
+ disconnectWebRTC(fv); fv.style.display = 'none';
858
+ const fs = document.getElementById('fs-panel');
859
+ fs.classList.remove('open', 'recording');
860
+ fs.dataset.cam = '';
861
+ document.getElementById('fs-btns').innerHTML = '';
862
+ const ov = document.getElementById('fs-body').querySelector('.ptz-overlay');
863
+ if (ov) ov.remove();
864
+ }
865
+ document.getElementById('fs-panel').addEventListener('click', e => {
866
+ if (e.target === document.getElementById('fs-panel')) closeFs();
867
+ });
868
+
869
+ /* ─────────────────────────── Recordings ────────────────────── */
870
+ function buildRecCamSel() {
871
+ const sel = document.getElementById('rec-cam-sel');
872
+ const last = localStorage.getItem('advr_rec_cam') || '';
873
+ sel.innerHTML = '<option value="">Kamera wählen…</option>';
874
+ Object.entries(cameras).forEach(([k, cam]) => {
875
+ const o = document.createElement('option'); o.value = k; o.textContent = cam.name;
876
+ sel.appendChild(o);
877
+ });
878
+ document.getElementById('rec-view-grid').classList.toggle('active', recViewMode === 'grid');
879
+ document.getElementById('rec-view-tl').classList.toggle('active', recViewMode === 'timeline');
880
+ if (last && cameras[last]) { sel.value = last; loadAndFilterRec(); }
881
+ }
882
+
883
+ async function loadAndFilterRec() {
884
+ const key = document.getElementById('rec-cam-sel').value;
885
+ if (!key) return;
886
+ localStorage.setItem('advr_rec_cam', key);
887
+ const grid = document.getElementById('rec-grid');
888
+ grid.innerHTML = '<div class="rec-empty">Lade…</div>';
889
+ document.getElementById('rec-tags').innerHTML = '';
890
+ recAllItems = [];
891
+
892
+ try {
893
+ const r = await fetch('/agent-dvr/api/recordings?camKey=' + encodeURIComponent(key),
894
+ { signal: AbortSignal.timeout(8000) });
895
+ if (!r.ok) throw new Error('HTTP ' + r.status);
896
+ const data = await r.json();
897
+ recAllItems = Array.isArray(data) ? data : [];
898
+
899
+ if (!recAllItems.length) {
900
+ grid.innerHTML = '<div class="rec-empty">Keine Aufnahmen.<br><small>enableEventDps in Adapter-Config prüfen.</small></div>';
901
+ return;
902
+ }
903
+
904
+ // Build tag chips
905
+ const tags = [...new Set(recAllItems.map(r => r.tag).filter(Boolean))];
906
+ const tagBar = document.getElementById('rec-tags');
907
+ recFilters.tag = '';
908
+ if (tags.length) {
909
+ const all = document.createElement('span'); all.className = 'tag-chip on'; all.textContent = 'Alle';
910
+ all.onclick = () => { recFilters.tag = ''; setActiveTag(null); renderRec(); };
911
+ tagBar.appendChild(all);
912
+ tags.forEach(t => {
913
+ const tc = document.createElement('span'); tc.className = 'tag-chip'; tc.textContent = t; tc.dataset.tag = t;
914
+ tc.onclick = () => { recFilters.tag = t; setActiveTag(t); renderRec(); };
915
+ tagBar.appendChild(tc);
916
+ });
917
+ }
918
+ renderRec();
919
+ } catch (e) {
920
+ grid.innerHTML = '<div class="rec-empty">Fehler: ' + esc(e.message) + '</div>';
921
+ }
922
+ }
923
+
924
+ function setActiveTag(tag) {
925
+ document.querySelectorAll('#rec-tags .tag-chip').forEach(c => {
926
+ c.classList.toggle('on', tag === null ? c.textContent === 'Alle' : c.dataset.tag === tag);
927
+ });
928
+ }
929
+
930
+ function filterRec() { renderRec(); }
931
+
932
+ function setRecView(mode) {
933
+ recViewMode = mode;
934
+ localStorage.setItem('advr_rec_view', mode);
935
+ document.getElementById('rec-view-grid').classList.toggle('active', mode === 'grid');
936
+ document.getElementById('rec-view-tl').classList.toggle('active', mode === 'timeline');
937
+ renderRec();
938
+ }
939
+
940
+ function renderRec() {
941
+ const q = (document.getElementById('rec-search').value || '').toLowerCase();
942
+ const tag = recFilters.tag;
943
+ const grid = document.getElementById('rec-grid');
944
+ const camKey = document.getElementById('rec-cam-sel').value;
945
+ const cam = cameras[camKey];
946
+
947
+ const items = recAllItems.filter(r => {
948
+ if (tag && r.tag !== tag) return false;
949
+ if (q) {
950
+ const hay = (r.date + ' ' + r.time + ' ' + (r.tag || '')).toLowerCase();
951
+ if (!hay.includes(q)) return false;
952
+ }
953
+ return true;
954
+ });
955
+
956
+ document.getElementById('rec-count').textContent = items.length + ' Aufnahme' + (items.length !== 1 ? 'n' : '');
957
+ grid.innerHTML = '';
958
+
959
+ if (!items.length) {
960
+ grid.innerHTML = '<div class="rec-empty">Keine Treffer.</div>';
961
+ return;
962
+ }
963
+
964
+ // Sort oldest→newest so "Nächste" (+1) = chronologically later recording
965
+ recPlayList = [...items].sort((a, b) => {
966
+ const da = (a.date || '') + 'T' + (a.time || '');
967
+ const db = (b.date || '') + 'T' + (b.time || '');
968
+ return da.localeCompare(db);
969
+ });
970
+
971
+ if (recViewMode === 'timeline') { renderTimeline(items, cam); return; }
972
+
973
+ grid.className = 'rec-grid';
974
+ items.forEach(rec => {
975
+ const tile = document.createElement('div'); tile.className = 'rec-tile';
976
+ const wrap = document.createElement('div'); wrap.className = 'rec-thumb-wrap';
977
+ const img = document.createElement('img'); img.className = 'rec-thumb'; img.alt = '';
978
+ img.loading = 'lazy';
979
+ img.src = rec.thumb || (cam && cam.snapshotUrl) || '';
980
+ const play = document.createElement('div'); play.className = 'rec-play-icon';
981
+ play.innerHTML = `<svg width="40" height="40" viewBox="0 0 40 40">
982
+ <circle cx="20" cy="20" r="20" fill="rgba(0,0,0,.6)"/>
983
+ <polygon points="16,12 30,20 16,28" fill="#fff"/>
984
+ </svg>`;
985
+ const badges = document.createElement('div'); badges.className = 'rec-badges';
986
+ if (rec.tag) {
987
+ const b = document.createElement('span'); b.className = 'badge tag'; b.textContent = rec.tag; badges.appendChild(b);
988
+ }
989
+ wrap.append(img, play, badges);
990
+
991
+ const info = document.createElement('div'); info.className = 'rec-info';
992
+ const date = document.createElement('div'); date.className = 'rec-date'; date.textContent = rec.date + ' ' + rec.time;
993
+ const meta = document.createElement('div'); meta.className = 'rec-meta';
994
+ meta.textContent = [rec.size ? rec.size + ' MB' : '', rec.dur ? rec.dur + 's' : ''].filter(Boolean).join(' · ');
995
+ info.append(date, meta);
996
+ tile.append(wrap, info);
997
+ tile.onclick = () => playRec(rec, cam, recPlayList.indexOf(rec));
998
+ grid.appendChild(tile);
999
+ });
1000
+ }
1001
+
1002
+ function tlZoom(dir) {
1003
+ const idx = TL_ZOOMS.indexOf(tlZoomLevel);
1004
+ const newIdx = Math.max(0, Math.min(TL_ZOOMS.length - 1, idx + dir));
1005
+ if (newIdx === idx) return;
1006
+ tlZoomLevel = TL_ZOOMS[newIdx];
1007
+ localStorage.setItem('advr_tl_zoom', tlZoomLevel);
1008
+ renderRec();
1009
+ }
1010
+
1011
+ function tlTickInterval(zoom) {
1012
+ const hoursVisible = 24 / zoom;
1013
+ if (hoursVisible <= 1) return 0.25;
1014
+ if (hoursVisible <= 3) return 0.5;
1015
+ if (hoursVisible <= 6) return 1;
1016
+ if (hoursVisible <= 12) return 2;
1017
+ return 6;
1018
+ }
1019
+
1020
+ function renderTimeline(items, cam) {
1021
+ const grid = document.getElementById('rec-grid');
1022
+ grid.className = 'tl-wrap';
1023
+
1024
+ // ── Zoom control ──
1025
+ const zoomRow = document.createElement('div'); zoomRow.className = 'tl-zoom';
1026
+ const zOut = document.createElement('button'); zOut.className = 'tl-zoom-btn';
1027
+ zOut.textContent = '−'; zOut.title = 'Rauszoomen';
1028
+ zOut.disabled = TL_ZOOMS.indexOf(tlZoomLevel) === 0;
1029
+ zOut.addEventListener('click', () => tlZoom(-1));
1030
+ const zLbl = document.createElement('span'); zLbl.className = 'tl-zoom-lbl';
1031
+ const hoursVis = 24 / tlZoomLevel;
1032
+ zLbl.textContent = hoursVis < 1 ? (hoursVis * 60) + 'min' : hoursVis + 'h';
1033
+ const zIn = document.createElement('button'); zIn.className = 'tl-zoom-btn';
1034
+ zIn.textContent = '+'; zIn.title = 'Reinzoomen';
1035
+ zIn.disabled = TL_ZOOMS.indexOf(tlZoomLevel) === TL_ZOOMS.length - 1;
1036
+ zIn.addEventListener('click', () => tlZoom(+1));
1037
+ zoomRow.append(zOut, zLbl, zIn);
1038
+ grid.appendChild(zoomRow);
1039
+
1040
+ // ── Scrollable container ──
1041
+ const scroll = document.createElement('div'); scroll.className = 'tl-scroll';
1042
+ const inner = document.createElement('div'); inner.className = 'tl-inner';
1043
+ inner.style.width = (tlZoomLevel * 100) + '%';
1044
+ scroll.appendChild(inner);
1045
+ grid.appendChild(scroll);
1046
+
1047
+ // ── Days ──
1048
+ const byDate = {};
1049
+ items.forEach(r => { (byDate[r.date] = byDate[r.date] || []).push(r); });
1050
+ const tickInterval = tlTickInterval(tlZoomLevel);
1051
+
1052
+ Object.keys(byDate).sort().reverse().forEach(date => {
1053
+ const recs = byDate[date].sort((a, b) => a.time.localeCompare(b.time));
1054
+ const row = document.createElement('div'); row.className = 'tl-day';
1055
+ const label = document.createElement('div'); label.className = 'tl-day-label'; label.textContent = date;
1056
+ const bar = document.createElement('div'); bar.className = 'tl-bar';
1057
+
1058
+ // Adaptive ticks
1059
+ for (let h = 0; h < 24; h += tickInterval) {
1060
+ const tick = document.createElement('span'); tick.className = 'tl-tick';
1061
+ tick.style.left = (h / 24 * 100) + '%';
1062
+ const hh = Math.floor(h), mm = Math.round((h - hh) * 60);
1063
+ tick.textContent = String(hh).padStart(2,'0') + ':' + String(mm).padStart(2,'0');
1064
+ bar.appendChild(tick);
1065
+ }
1066
+
1067
+ recs.forEach(rec => {
1068
+ const [hh, mm, ss] = rec.time.split(':').map(Number);
1069
+ const startSec = (hh||0)*3600 + (mm||0)*60 + (ss||0);
1070
+ const dur = rec.dur || 60;
1071
+ const left = (startSec / 86400 * 100).toFixed(3);
1072
+ const width = Math.max(0.2 / tlZoomLevel, dur / 86400 * 100).toFixed(3);
1073
+ const globalIdx = recPlayList.indexOf(rec);
1074
+
1075
+ const block = document.createElement('div'); block.className = 'tl-block';
1076
+ block.style.left = left + '%';
1077
+ block.style.width = width + '%';
1078
+ if (rec.tag) block.dataset.tag = rec.tag;
1079
+ block.title = rec.time
1080
+ + (rec.dur ? ' · ' + rec.dur + 's' : '')
1081
+ + (rec.size ? ' · ' + rec.size + ' MB' : '')
1082
+ + (rec.tag ? ' · ' + rec.tag : '');
1083
+ block.addEventListener('click', () => playRec(rec, cam, globalIdx));
1084
+ bar.appendChild(block);
1085
+ });
1086
+
1087
+ row.append(label, bar);
1088
+ inner.appendChild(row);
1089
+ });
1090
+ }
1091
+
1092
+ function updateNavBtns() {
1093
+ document.getElementById('vid-prev').disabled = recPlayIdx <= 0;
1094
+ document.getElementById('vid-next').disabled = recPlayIdx >= recPlayList.length - 1;
1095
+ document.getElementById('vid-pos').textContent =
1096
+ recPlayList.length ? (recPlayIdx + 1) + ' / ' + recPlayList.length : '';
1097
+ }
1098
+ function playRecIdx(idx) {
1099
+ if (idx < 0 || idx >= recPlayList.length) return;
1100
+ const camKey = document.getElementById('rec-cam-sel').value;
1101
+ playRec(recPlayList[idx], cameras[camKey], idx);
1102
+ }
1103
+ function playRec(rec, cam, idx) {
1104
+ const url = rec.video || rec.url || '';
1105
+ if (!url) return;
1106
+ const src = url.startsWith('http') ? url : BASE + url;
1107
+ const el = document.getElementById('vid-el');
1108
+ if (el.src !== src) { el.src = src; el.load(); }
1109
+ recPlayIdx = idx !== undefined ? idx : recPlayList.indexOf(rec);
1110
+ document.getElementById('vid-title').textContent = rec.date + ' ' + rec.time;
1111
+ document.getElementById('vid-cam').textContent = cam ? cam.name : '';
1112
+ document.getElementById('vid-meta-txt').textContent =
1113
+ [rec.size ? rec.size + ' MB' : '', rec.dur ? rec.dur + 's' : '', rec.tag || ''].filter(Boolean).join(' · ');
1114
+ document.getElementById('vid-dl').href = src;
1115
+ updateNavBtns();
1116
+ document.getElementById('vid-panel').classList.add('open');
1117
+ el.play().catch(() => {});
1118
+ }
1119
+ function closeVideo() {
1120
+ const el = document.getElementById('vid-el');
1121
+ el.pause(); el.removeAttribute('src'); el.load();
1122
+ document.getElementById('vid-panel').classList.remove('open');
1123
+ }
1124
+ document.getElementById('vid-panel').addEventListener('click', e => {
1125
+ if (e.target === document.getElementById('vid-panel')) closeVideo();
1126
+ });
1127
+
1128
+ /* ─────────────────────────── Banner / Status ─────────────────── */
1129
+ function showBanner(html, retry) {
1130
+ const b = document.getElementById('info-banner');
1131
+ b.innerHTML = html + (retry ? '<button onclick="location.reload()">Neu laden</button>' : '');
1132
+ b.classList.add('visible');
1133
+ }
1134
+ function hideBanner() { document.getElementById('info-banner').classList.remove('visible'); }
1135
+ function sbar(msg, err) {
1136
+ const s = document.getElementById('sbar-msg');
1137
+ s.textContent = msg; s.className = err ? 'err' : '';
1138
+ }
1139
+ function connDot(on) { document.getElementById('conn-dot').className = on ? 'on' : ''; }
1140
+ function esc(s) {
1141
+ return String(s == null ? '' : s)
1142
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
1143
+ }
1144
+
1145
+ /* ─────────────────────────── Keyboard shortcuts ─────────────── */
1146
+ document.addEventListener('keydown', e => {
1147
+ if (e.key === 'Escape') { closeFs(); closeVideo(); }
1148
+ if (document.getElementById('vid-panel').classList.contains('open')) {
1149
+ if (e.key === 'ArrowLeft') { e.preventDefault(); playRecIdx(recPlayIdx - 1); }
1150
+ if (e.key === 'ArrowRight') { e.preventDefault(); playRecIdx(recPlayIdx + 1); }
1151
+ }
1152
+ if (e.key === 'f' && !e.ctrlKey && !e.metaKey && document.activeElement.tagName !== 'INPUT') {
1153
+ const fp = document.getElementById('fs-panel');
1154
+ if (fp.classList.contains('open')) closeFs();
1155
+ }
1156
+ });
1157
+
1158
+ /* Tab hidden → stop streams; tab visible → restart */
1159
+ document.addEventListener('visibilitychange', () => {
1160
+ if (document.hidden) {
1161
+ // Stop MJPEG
1162
+ document.querySelectorAll('.cam-tile img[data-mjpeg]').forEach(img => {
1163
+ img.dataset.hiding = '1';
1164
+ img.src = '';
1165
+ setTimeout(() => { delete img.dataset.hiding; }, 400);
1166
+ });
1167
+ // Stop WebRTC on tiles
1168
+ document.querySelectorAll('.cam-tile video[data-stream]').forEach(vid => disconnectWebRTC(vid));
1169
+ // Stop fullscreen streams
1170
+ const fi = document.getElementById('fs-img');
1171
+ if (fi.src) { fi.dataset.s = fi.src; fi.src = ''; }
1172
+ const fv = document.getElementById('fs-vid');
1173
+ if (fv && fv.dataset.stream) disconnectWebRTC(fv);
1174
+ } else {
1175
+ // Restart MJPEG
1176
+ document.querySelectorAll('.cam-tile img[data-mjpeg]').forEach(img => {
1177
+ img.dataset.retry = '0';
1178
+ img.src = img.dataset.mjpeg;
1179
+ });
1180
+ // Restart WebRTC on tiles
1181
+ if (CFG.go2rtcUrl) {
1182
+ document.querySelectorAll('.cam-tile video[data-stream]').forEach(vid =>
1183
+ connectWebRTC(vid, CFG.go2rtcUrl, vid.dataset.stream));
1184
+ }
1185
+ // Restart fullscreen streams
1186
+ const fi = document.getElementById('fs-img');
1187
+ if (!fi.src && fi.dataset.s) { fi.src = fi.dataset.s; }
1188
+ const fv = document.getElementById('fs-vid');
1189
+ if (fv && fv.dataset.stream && CFG.go2rtcUrl) connectWebRTC(fv, CFG.go2rtcUrl, fv.dataset.stream);
1190
+ }
1191
+ });
1192
+
1193
+ /* ─────────────────────────── WebRTC (go2rtc) ───────────────── */
1194
+ async function connectWebRTC(vid, baseUrl, streamName) {
1195
+ disconnectWebRTC(vid);
1196
+ if (!baseUrl || !streamName) return;
1197
+ try {
1198
+ const pc = new RTCPeerConnection({
1199
+ iceServers: [{urls:['stun:stun.cloudflare.com:3478','stun:stun.l.google.com:19302']}]
1200
+ });
1201
+ vid._rtcPc = pc;
1202
+ vid.dataset.stream = streamName;
1203
+
1204
+ const stream = new MediaStream();
1205
+ vid.srcObject = stream;
1206
+ pc.addEventListener('track', ev => stream.addTrack(ev.track));
1207
+
1208
+ pc.addTransceiver('video', {direction:'recvonly'});
1209
+ pc.addTransceiver('audio', {direction:'recvonly'});
1210
+
1211
+ // Route through ioBroker proxy to avoid browser cross-origin block on go2rtc
1212
+ const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1213
+ const wsUrl = wsProto + '//' + location.host + '/agent-dvr/api/ws?src=' + encodeURIComponent(streamName);
1214
+ console.log('[go2rtc] connecting via proxy', wsUrl);
1215
+ const ws = new WebSocket(wsUrl);
1216
+ vid._rtcWs = ws;
1217
+
1218
+ ws.addEventListener('open', () => {
1219
+ console.log('[go2rtc] WS open, creating offer for', streamName);
1220
+ pc.addEventListener('icecandidate', ev => {
1221
+ if (!ev.candidate || ws.readyState !== WebSocket.OPEN) return;
1222
+ ws.send(JSON.stringify({type:'webrtc/candidate', value:ev.candidate.candidate}));
1223
+ });
1224
+ pc.createOffer()
1225
+ .then(o => pc.setLocalDescription(o))
1226
+ .then(() => {
1227
+ if (ws.readyState === WebSocket.OPEN)
1228
+ ws.send(JSON.stringify({type:'webrtc/offer', value:pc.localDescription.sdp}));
1229
+ });
1230
+ });
1231
+ ws.addEventListener('message', ev => {
1232
+ try {
1233
+ const msg = JSON.parse(ev.data);
1234
+ if (msg.type === 'webrtc/candidate') {
1235
+ pc.addIceCandidate({candidate:msg.value, sdpMid:'0'}).catch(() => {});
1236
+ } else if (msg.type === 'webrtc/answer') {
1237
+ console.log('[go2rtc] got answer for', streamName);
1238
+ pc.setRemoteDescription({type:'answer', sdp:msg.value});
1239
+ }
1240
+ } catch (_) {}
1241
+ });
1242
+ ws.addEventListener('error', () => console.warn('[go2rtc] WS error for stream', streamName));
1243
+ ws.addEventListener('close', ev => console.warn('[go2rtc] WS closed', ev.code, streamName));
1244
+ pc.addEventListener('connectionstatechange', () =>
1245
+ console.log('[go2rtc] PC state:', pc.connectionState, streamName));
1246
+ } catch (err) {
1247
+ console.error('[go2rtc] connectWebRTC failed:', err);
1248
+ }
1249
+ }
1250
+ function disconnectWebRTC(vid) {
1251
+ if (!vid) return;
1252
+ try { if (vid._rtcWs) { vid._rtcWs.close(); vid._rtcWs = null; } } catch (_) {}
1253
+ try { if (vid._rtcPc) { vid._rtcPc.close(); vid._rtcPc = null; } } catch (_) {}
1254
+ vid.srcObject = null;
1255
+ }
1256
+
1257
+ /* ─────────────────────────── Boot ──────────────────────────── */
1258
+ loadConfig().then(() => loadData()).then(() => ensureLayoutObserver());
1259
+ </script>
1260
+ <script src="/socket.io/socket.io.js"></script>
1261
+ </body>
1262
+ </html>