Files
homeclaw/web-ui/html/nvr-app.html
T
kimandClaude Sonnet 5 a8149c136a feat: 프린터실 카메라 웹코덱(WebRTC/MSE) 전환 + K2 콤보 카메라 프록시, 옛 3D프린터 앱 제거
- go2rtc가 CORS/Origin 체크로 브라우저 직접접속을 막던 문제 해결: 클라이언트
  스크립트를 벤더링해 같은 오리진으로 서빙하고, WS는 게이트웨이 프록시
  (attachGo2rtcWsProxy)가 Origin을 다시 써서 우회. CCTV 앱의 프린터실 카메라
  라이브뷰가 MJPEG 대신 WebRTC/MSE를 쓰도록 전환(대역폭 절감).
- go2rtc mjpeg 트랜스코더가 멈추는 경우를 감지해 자동 재기동하는
  /api/nvr/mjpeg-health 엔드포인트 추가.
- 신규 3D프린터(K2 콤보, Klipper 스탠드얼론)의 내장 WebRTC 카메라 시그널링을
  프록시하는 /api/k2/webrtc-signal 추가 — 홈클로가 HTTPS로 서빙되는데 카메라가
  평문 HTTP만 지원해 브라우저 mixed-content 차단을 우회하기 위함.
- 옛 3D프린터(단종된 Anet A8 Plus, OctoPrint) 앱과 홈 드롭다운 링크 제거.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PppKcQmeTJDQCpfcgKietu
2026-09-18 22:40:10 +09:00

796 lines
44 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📹 홈 CCTV</title>
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
<link rel="stylesheet" href="../css/styles.css">
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
:root{--brand:#22c55e;}
html,body{height:100%;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
.hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
.hdr h1{font-size:13px;font-weight:700;margin:0;}
.hdr-sp{flex:1;}
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
.hdr-btn:hover{border-color:var(--brand);color:var(--brand);}
.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;}
.tab-btn{padding:7px 16px;font-size:12px;font-weight:600;cursor:pointer;background:none;border:none;color:var(--muted);font-family:inherit;border-bottom:2px solid transparent;transition:.15s;margin-bottom:-1px;}
.tab-btn:hover{color:var(--text);}
.tab-btn.active{color:var(--brand);border-bottom-color:var(--brand);}
.tab-pane{display:none;flex:1;min-height:0;overflow-y:auto;}
.tab-pane.active{display:block;}
.wrap{padding:14px;width:80%;margin:0 auto;}
.dev-info{font-size:11px;color:var(--muted);margin-bottom:14px;padding:8px 12px;background:var(--panel);border:1px solid var(--line);border-radius:8px;}
.dev-info b{color:var(--text);}
.chn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px;}
.chn-card{background:var(--panel);border:1.5px solid var(--line);border-radius:10px;padding:12px;position:relative;}
.chn-card.offline{opacity:.55;}
.chn-top{display:flex;align-items:center;gap:6px;margin-bottom:6px;}
.chn-dot{width:8px;height:8px;border-radius:50%;background:#888;flex-shrink:0;}
.chn-dot.on{background:#22c55e;box-shadow:0 0 6px #22c55e;}
.chn-name{font-size:13px;font-weight:700;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.chn-id{font-size:10px;color:var(--muted);}
.chn-edit{background:none;border:none;color:var(--muted);cursor:pointer;font-size:12px;padding:2px 4px;font-family:inherit;flex-shrink:0;}
.chn-edit:hover{color:var(--brand);}
.chn-row{font-size:11px;color:var(--muted);display:flex;justify-content:space-between;margin-top:3px;}
.chn-badges{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px;}
.badge{font-size:9px;font-weight:700;padding:2px 6px;border-radius:8px;background:var(--panel-2);color:var(--muted);}
.badge.warn{background:rgba(245,158,11,.15);color:#f59e0b;}
.badge.rec{background:rgba(239,68,68,.15);color:#ef4444;}
.evt-toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
.evt-toolbar input{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12px;color:var(--text);font-family:inherit;}
.evt-toolbar button{background:var(--brand);border:none;border-radius:6px;padding:6px 14px;font-size:12px;font-weight:700;color:#0b1a10;cursor:pointer;font-family:inherit;}
.evt-table{width:100%;border-collapse:collapse;font-size:11px;}
.evt-table th{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);color:var(--muted);font-weight:700;}
.evt-table td{padding:6px 8px;border-bottom:1px solid var(--line);}
.empty{color:var(--muted);font-size:12px;text-align:center;padding:30px;}
.set-form{display:flex;flex-direction:column;gap:10px;max-width:360px;}
.set-field{display:flex;flex-direction:column;gap:4px;}
.set-field label{font-size:11px;font-weight:700;color:var(--muted);}
.set-field input{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:7px 8px;font-size:12px;color:var(--text);font-family:inherit;}
.set-btn{background:var(--brand);border:none;border-radius:8px;padding:9px;font-size:13px;font-weight:700;color:#0b1a10;cursor:pointer;font-family:inherit;}
.set-hint{font-size:10px;color:var(--muted);}
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 16px;font-size:12px;box-shadow:0 4px 20px rgba(0,0,0,.3);display:none;z-index:100;}
.toast.show{display:block;}
.toast.err{border-color:#ef4444;color:#ef4444;}
.chn-live{margin-top:8px;background:var(--brand);border:none;border-radius:6px;padding:5px 0;width:100%;font-size:11px;font-weight:700;color:#0b1a10;cursor:pointer;font-family:inherit;transition:.15s;}
.chn-live:hover{opacity:.88;}
.chn-live:disabled{background:var(--panel-2);color:var(--muted);cursor:not-allowed;opacity:.6;}
.quad-wrap{margin-bottom:16px;}
.quad-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px;}
.quad-toolbar b{font-size:13px;}
.quad-hint{font-size:10px;color:var(--muted);}
.quad-sp{flex:1;}
.quad-quality{font-size:11px;color:var(--muted);display:flex;align-items:center;gap:4px;cursor:pointer;}
.quad-grid{display:grid;grid-template-columns:repeat(2,1fr);grid-template-rows:repeat(3,1fr);gap:6px;background:#000;border:1px solid var(--line);border-radius:10px;padding:6px;aspect-ratio:32/27;}
.quad-cell{position:relative;background:#000;border-radius:6px;overflow:hidden;cursor:pointer;min-height:0;display:flex;align-items:center;justify-content:center;}
.quad-cell.empty-slot{background:var(--panel-2);cursor:default;}
.quad-cell img{width:100%;height:100%;object-fit:contain;display:block;}
.quad-cell .ph{color:var(--muted);font-size:12px;text-align:center;padding:10px;line-height:1.5;}
.quad-cell .lbl{position:absolute;top:4px;left:6px;font-size:11px;font-weight:700;color:#fff;background:rgba(0,0,0,.45);padding:2px 7px;border-radius:5px;pointer-events:none;}
.chn-details{margin-top:6px;}
.chn-details summary{font-size:12px;color:var(--muted);cursor:pointer;padding:6px 0;}
.live-modal{position:fixed;inset:0;background:rgba(0,0,0,.92);display:none;flex-direction:column;align-items:center;justify-content:center;z-index:200;}
.live-modal.show{display:flex;}
.live-bar{position:absolute;top:0;left:0;right:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:rgba(0,0,0,.4);}
.live-bar .lt{font-size:14px;font-weight:700;color:#fff;}
.live-bar .sp{flex:1;}
.live-close{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);color:#fff;border-radius:6px;padding:5px 14px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;}
.live-close:hover{background:rgba(255,255,255,.2);}
.live-img{max-width:96vw;max-height:88vh;border-radius:4px;background:#000;}
.live-err{color:#ef4444;font-size:13px;max-width:80vw;text-align:center;padding:20px;}
.ptz-panel{position:absolute;right:14px;top:56px;background:color-mix(in srgb,var(--panel) 55%,transparent);backdrop-filter:blur(3px);border:1px solid var(--line);border-radius:8px;padding:8px;display:flex;flex-direction:column;gap:6px;user-select:none;opacity:.55;transition:opacity .15s;}
.ptz-panel:hover,.ptz-panel:focus-within{opacity:.95;}
.ptz-pad{display:grid;grid-template-columns:repeat(3,28px);grid-template-rows:repeat(3,28px);gap:3px;}
.ptz{background:color-mix(in srgb,var(--panel-2) 70%,transparent);border:1px solid var(--line);border-radius:5px;font-size:12px;color:var(--text);cursor:pointer;font-family:inherit;display:flex;align-items:center;justify-content:center;transition:.1s;}
.ptz:active,.ptz:active:focus{background:var(--brand);color:#0b1a10;border-color:var(--brand);}
.ptz.stop{font-size:9px;}
.ptz-speed{font-size:10px;color:var(--muted);display:flex;align-items:center;gap:4px;}
.ptz-speed input{width:70px;}
.live-ctrl{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px;}
.live-ctrl label{font-size:12px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;}
.live-ctrl input,.live-ctrl select{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12px;color:var(--text);font-family:inherit;}
.live-play{background:var(--brand);border:none;border-radius:6px;padding:7px 16px;font-size:12px;font-weight:700;color:#0b1a10;cursor:pointer;font-family:inherit;}
.live-play:hover{opacity:.88;}
.live-stop{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:7px 14px;font-size:12px;font-weight:700;color:var(--text);cursor:pointer;font-family:inherit;}
.live-preview{background:#000;border:1px solid var(--line);border-radius:10px;min-height:320px;display:flex;align-items:center;justify-content:center;overflow:hidden;}
.live-preview img{width:100%;max-height:78vh;object-fit:contain;display:block;}
.live-preview .empty{color:var(--muted);font-size:13px;text-align:center;padding:30px;line-height:1.7;}
.rec-section{margin-top:20px;}
.rec-hdr{display:flex;gap:8px;align-items:center;margin-bottom:6px;}
.rec-hdr input,.rec-hdr button{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12px;color:var(--text);font-family:inherit;cursor:pointer;}
.rec-table{width:100%;border-collapse:collapse;font-size:11px;}
.rec-table th{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);color:var(--muted);font-weight:700;}
.rec-table td{padding:6px 8px;border-bottom:1px solid var(--line);}
video-stream{width:100%;height:100%;display:block;background:#000;}
</style>
<script type="module" src="/vendor/go2rtc/video-stream.js"></script>
</head>
<body>
<header class="hdr">
<a href="/" style="text-decoration:none;font-size:13px;color:var(--muted)">← 홈</a>
<h1>📹 홈 CCTV</h1>
<span class="hdr-sp"></span>
<button class="hdr-btn" onclick="loadChannels()">↺ 새로고침</button>
<button class="hdr-btn" onclick="toggleTheme()">🌙</button>
</header>
<div class="tab-bar">
<button class="tab-btn active" id="tab-chn" onclick="switchTab('chn')">📷 채널</button>
<button class="tab-btn" id="tab-live" onclick="switchTab('live')">▶ 라이브</button>
<button class="tab-btn" id="tab-evt" onclick="switchTab('evt')">📋 이벤트 로그</button>
<button class="tab-btn" id="tab-set" onclick="switchTab('set')">⚙️ 설정</button>
</div>
<div class="tab-pane active" id="pane-chn">
<div class="wrap">
<div class="dev-info" id="dev-info">장치 정보 로딩 중...</div>
<div class="quad-wrap">
<div class="quad-toolbar">
<b>🔊 6분할 라이브</b>
<span class="quad-hint" id="quad-hint"></span>
<span class="quad-sp"></span>
<button class="hdr-btn" id="quad-play-btn" onclick="toggleQuad()">▶ 재생</button>
<label class="quad-quality"><input type="checkbox" id="quad-main" onchange="if(quadPlaying){stopQuadStreams();startQuadStreams();}"> 메인(고화질)</label>
</div>
<div class="quad-grid" id="quad-grid"></div>
</div>
<details class="chn-details"><summary>상세 채널 정보</summary>
<div style="display:flex;justify-content:flex-end;margin:8px 0">
<button class="hdr-btn" onclick="addExtraCamera()">+ 독립 카메라 추가</button>
</div>
<div class="chn-grid" id="chn-grid"><div class="empty">채널 로딩 중...</div></div>
</details>
</div>
</div>
<div class="tab-pane" id="pane-live">
<div class="wrap">
<div class="live-ctrl">
<label>채널 <span class="set-hint">(0=첫카메라)</span> <input type="number" id="live-ch" min="0" value="0" style="width:70px"></label>
<label>스트림
<select id="live-stream">
<option value="0">메인(0)</option>
<option value="1">서브(1)</option>
</select>
</label>
<button class="live-play" onclick="playManual()">▶ 재생</button>
<button class="live-stop" onclick="stopManual()">■ 정지</button>
</div>
<div class="live-preview" id="live-preview">
<div class="empty">채널 번호·스트림을 고르고 ▶ 재생을 누르면 RTSP 라이브 영상이 여기 표시됩니다.<br><span class="set-hint">영상이 안 나오면 설정 탭에서 RTSP 포트·경로 템플릿을 확인하고, NVR 웹UI에서 RTSP 서비스가 켜져 있는지 확인하세요.</span></div>
</div>
<div class="rec-section">
<div class="rec-hdr">
<b>🎞 녹화 목록</b>
<input type="date" id="rec-date" style="width:150px">
<button class="hdr-btn" onclick="loadRecords()">조회</button>
<span class="set-hint" id="rec-count"></span>
</div>
<div class="set-hint" style="margin-bottom:8px">이 NVR은 녹화 재생/다운로드가 IE 전용 ActiveX로만 지원돼 웹으로는 목록 조회만 가능합니다(시각·채널·용량).</div>
<div class="rec-list" id="rec-list"><div class="empty">날짜를 고르고 조회를 누르세요.</div></div>
</div>
</div>
</div>
<div class="tab-pane" id="pane-evt">
<div class="wrap">
<div class="evt-toolbar">
<input type="datetime-local" id="evt-begin">
<span style="color:var(--muted);font-size:12px;">~</span>
<input type="datetime-local" id="evt-end">
<button onclick="loadEvents()">조회</button>
</div>
<div id="evt-result"><div class="empty">조회 버튼을 눌러 로그를 확인하세요.</div></div>
</div>
</div>
<div class="tab-pane" id="pane-set">
<div class="wrap">
<form class="set-form" onsubmit="saveConfig(event)" autocomplete="off">
<div class="set-field">
<label>NVR 호스트(IP)</label>
<input type="text" id="set-host" placeholder="192.168.0.95" autocomplete="off">
</div>
<div class="set-field">
<label>포트 (HTTP REST)</label>
<input type="number" id="set-port" placeholder="80" autocomplete="off">
</div>
<div class="set-field">
<label>RTSP 포트 <span class="set-hint">(라이브 영상용, NVR 웹UI에서 RTSP 활성화 필요)</span></label>
<input type="number" id="set-rtspport" placeholder="554" autocomplete="off">
</div>
<div class="set-field">
<label>RTSP 경로 템플릿 <span class="set-hint">(고급 · 비우면 기본 Movols 형식)</span></label>
<input type="text" id="set-rtsp-path" placeholder="/ch{channel}_{stream}.264" autocomplete="off">
<div class="set-hint">치환자: {username} {password} {channel}(=채널 ID 그대로) {channel0}(=ID-1) {stream}(0메인/1서브). <b>이 기기 정답: <code>/ch{channel}_{stream}.264</code></b> (채널 ID가 0-based라 ch{channel} 직결). '/' 자동 붙음.</div>
</div>
<div class="set-field">
<label>아이디</label>
<input type="text" id="set-username" placeholder="admin" autocomplete="off">
</div>
<div class="set-field">
<label>비밀번호 <span id="set-pw-hint" class="set-hint"></span></label>
<input type="password" id="set-password" placeholder="변경 시에만 입력" autocomplete="new-password">
</div>
<button class="set-btn" type="submit">저장</button>
<div class="set-hint">비밀번호는 vault에 암호화 저장됩니다. 채널 상태·이벤트 로그는 HTTP REST(위 포트)로 조회. 라이브 영상(MJPEG)은 RTSP 포트로 <code>rtsp://…/ch{channel}_{stream}.264</code> 접속 — 이 기기는 RTSP가 80번에서 이미 동작(활성화 불필요).</div>
</form>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="live-modal" id="live-modal">
<div class="live-bar">
<span class="lt" id="live-title">라이브</span>
<span class="sp"></span>
<button class="live-close" onclick="closeLive()">✕ 닫기</button>
</div>
<img class="live-img" id="live-img" alt="">
<video-stream id="live-vs" mode="webrtc,mse,mp4,mjpeg" style="display:none"></video-stream>
<div class="live-err" id="live-err" style="display:none"></div>
<div class="ptz-panel" id="ptz-panel">
<div class="ptz-pad">
<button class="ptz" data-cmd="LeftUp">↖</button>
<button class="ptz" data-cmd="Up">↑</button>
<button class="ptz" data-cmd="RightUp">↗</button>
<button class="ptz" data-cmd="Left">←</button>
<button class="ptz stop" data-cmd="Stop">●</button>
<button class="ptz" data-cmd="Right">→</button>
<button class="ptz" data-cmd="LeftDown">↙</button>
<button class="ptz" data-cmd="Down">↓</button>
<button class="ptz" data-cmd="RightDown">↘</button>
</div>
<div class="ptz-speed">속도 <input type="range" id="ptz-speed" min="1" max="10" value="5"></div>
</div>
</div>
<script>
const TOKEN_KEY='smallclaw_token';
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}}
function escHtml(s){return String(s ?? '').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function showToast(msg,isErr){const t=document.getElementById('toast');t.textContent=msg;t.className='toast show'+(isErr?' err':'');setTimeout(()=>t.classList.remove('show'),3000);}
// ── 라이브 영상 (RTSP → MJPEG) ──
// 서버 /api/nvr/stream/:id 가 multipart/x-mixed-replace MJPEG을 내보내면
// <img src> 로 브라우저가 자동 스트리밍. 닫을 때 **반드시 요소를 제거**하거나
// data URI로 src를 바꿔야 브라우저가 fetch를 중단 → 서버 res 'close' → ffmpeg SIGKILL.
// 주의: img.src='' (빈문자열) 은 페이지 URL로 해석돼 중단이 안 되고 ffmpeg가
// 좀비로 남아 NVR RTSP 세션을 계속 물고 있음 → 동시 세션수 초과로 502 발생.
const BLANK_IMG='data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACAkQBADs=';
// img.src 교체만으로는 브라우저 커넥션 재사용/keep-alive 때문에 서버 res 'close'가
// 늦게(또는 거의 안) 오는 경우가 있어, 화면 전환이 잦으면(카메라 넘기기 등) ffmpeg가
// 좀비로 쌓임 → NVR 동시세션 초과 → 502. 그래서 **서버에 즉시-컷을 명시적으로 요청**한다
// (POST .../stop, keepalive:true — 페이지 전환 중에도 요청이 살아남게).
function stopChannel(channelId,streamQ){
if(channelId==null) return;
fetch('/api/nvr/stream/'+channelId+'/stop?stream='+(streamQ||0),{method:'POST',headers:authH(),keepalive:true}).catch(()=>{});
}
function killImg(img,channelId,streamQ){ // multipart fetch 중단 + 서버 즉시-컷
if(!img) return;
img.onerror=null;
img.src=BLANK_IMG; // 새 src → 이전 요청 중단 시도(브라우저단, 느릴 수 있음)
stopChannel(channelId,streamQ); // 서버단 확실한 즉시 SIGKILL
}
let currentLiveChannel=null;
let currentLiveStream=0;
let currentLiveMode='mjpeg'; // 'mjpeg' | 'go2rtc' — go2rtcSrc 있는 카메라는 video-stream(WebRTC→MSE 자동폴백)으로 대역폭 절약
// go2rtc(1985)에 브라우저가 직접 붙으면 Origin 불일치로 거부당해(CheckOrigin) 게이트웨이의
// 프록시(/api/nvr/go2rtc-ws)를 거친다 — 같은 오리진으로 붙게 한 뒤 서버에서 Origin을 다시 씀.
const GO2RTC_WS='ws://192.168.0.5:18789/api/nvr/go2rtc-ws';
// 채널별로 강제 지정된 화질(preferredStream)이 있으면 그걸 쓰고, 없으면 null(화면 기본값 사용).
// 신호 약한 카메라는 메인이 계속 끊겨서 서브로 고정해두는 용도(2026-07-21).
function chnStreamOverride(channelId){
const c=lastChannels.find(x=>x.id===channelId);
if(!c) return null;
// NVR이 "Resolution too big"로 물고 있는 채널은 서브스트림(/chN_1.264)이 대개 안 나온다
// (메인 패스스루만 됨) → 메인 강제. chnStreamable()로 이미 6분할엔 포함된 상태.
if(/resolution too big/i.test(c.statusText||'')) return 0;
return (c.preferredStream===0||c.preferredStream===1) ? c.preferredStream : null;
}
function chnPreferredStream(channelId){ // 라이브 모달용 — 오버라이드 없으면 메인(0)
const o=chnStreamOverride(channelId);
return o==null?0:o;
}
function openLive(channelId,btn){
const img=document.getElementById('live-img');
// 모달이 이미 다른 채널을 보고 있는 채로 새 채널을 여는 경우(카메라 넘기기) —
// 이전 채널을 먼저 명시적으로 끊어야 함(안 그러면 좀비로 남아 세션 잠식).
if(currentLiveChannel!=null && currentLiveChannel!==channelId){
stopChannel(currentLiveChannel,currentLiveStream);
}
currentLiveChannel=channelId;
currentLiveStream=chnPreferredStream(channelId);
// 5분할이 재생중이면 잠시 정지 — NVR 동시 RTSP 세션 수 제한(보통 4) 때문에
// 5분할 자체가 이미 한계치라, 모달까지 더하면 NVR이 거부 → "영상 불러오지 못했습니다"(캠2 사례).
// 모달은 전체화면 오버레이라 5분할이 안 보이므로 정지해도 무방. 닫으면 재개.
if(quadPlaying){ stopQuadStreams(); }
const modal=document.getElementById('live-modal');
const errEl=document.getElementById('live-err');
const titleEl=document.getElementById('live-title');
errEl.style.display='none';errEl.textContent='';
titleEl.textContent=(btn?btn.getAttribute('data-name')||('채널 '+channelId):('채널 '+channelId))+(currentLiveStream?' (저화질 고정)':'');
const vs=document.getElementById('live-vs');
const ch=lastChannels.find(x=>x.id===channelId);
if(ch && ch.go2rtcSrc){
// go2rtc가 릴레이 중인 카메라 — WebRTC/MSE(브라우저 네이티브 H.264)로 재생, MJPEG보다
// 대역폭이 훨씬 적게 듦(2026-09-18, 원격뷰 대역폭 이슈로 도입). ffmpeg 파이프라인은 안 씀.
currentLiveMode='go2rtc';
img.style.display='none';
img.src='';
vs.style.display='block';
vs.src=GO2RTC_WS+'?src='+encodeURIComponent(ch.go2rtcSrc)+'&token='+encodeURIComponent(getToken());
} else {
currentLiveMode='mjpeg';
vs.style.display='none';
vs.src='';
img.style.display='block';
img.onerror=()=>{ // 서버가 502 JSON을 보내거나 프레임이 안 오면 img가 깨짐
img.style.display='none';
errEl.textContent='RTSP 영상을 불러오지 못했습니다. NVR 설정(호스트·계정·비밀번호)과 RTSP 경로를 확인하세요.';
errEl.style.display='block';
};
// 캐시 방지 위해 채널 id + 타임스탵 쿼리(같은 src 재사용시 onerror 안 트리는 브라우저 대비)
img.src='/api/nvr/stream/'+channelId+(currentLiveStream?'?stream=1':'');
}
modal.classList.add('show');
}
function closeLive(){
const closingChannel=currentLiveChannel;
const closingStream=currentLiveStream;
currentLiveChannel=null;
const modal=document.getElementById('live-modal');
const img=document.getElementById('live-img');
const vs=document.getElementById('live-vs');
if(currentLiveMode==='go2rtc'){
try{ vs.disconnectedCallback(); }catch(e){} // WS/WebRTC 연결 정리(라이브러리 내부 정리 로직 재사용)
vs.style.display='none';
} else {
killImg(img,closingChannel,closingStream); // 연결 종료 + 서버 즉시-컷
}
modal.classList.remove('show');
// 모달을 위해 멈췄던 5분할 재개(채널 탭이 보이는 중이고 재생중이었으면).
if(quadPlaying && isChnActive()){ startQuadStreams(); }
}
// ── PTZ 제어 (누르는 동안 이동, 놓으면 Stop) ──
async function ptzSend(command){
if(currentLiveChannel==null) return;
const speed=document.getElementById('ptz-speed').value||5;
try{
await fetch('/api/nvr/ptz',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({channel:currentLiveChannel,command,speed})});
}catch{}
}
// PTZ 버튼 press-and-hold 바인딩 (pointer = mouse+touch 통일)
(()=>{
const btns=document.querySelectorAll('.ptz');
btns.forEach(b=>{
const cmd=b.getAttribute('data-cmd');
let pressed=false;
const start=e=>{e.preventDefault();pressed=true;b.classList.add('active');ptzSend(cmd);};
const end=()=>{if(!pressed)return;pressed=false;b.classList.remove('active');ptzSend('Stop');};
b.addEventListener('pointerdown',start);
b.addEventListener('pointerup',end);
b.addEventListener('pointerleave',end);
b.addEventListener('pointercancel',end);
});
})();
// ── 라이브 탭: 수동 재생(채널·스트림 직접 지정) ──
let liveImg=null,liveErrEl=null,liveManualCh=null,liveManualStream=0;
function playManual(){
// 이미 재생중이던 채널이 있으면 먼저 명시적으로 끊음(화면 넘기기 좀비 방지).
if(liveManualCh!=null) stopChannel(liveManualCh,liveManualStream);
const chRaw=Number(document.getElementById('live-ch').value);
const ch=Number.isFinite(chRaw)?chRaw:0; // 0-based: 0=첫카메라
const stream=document.getElementById('live-stream').value; // '0'|'1'
liveManualCh=ch;liveManualStream=stream;
const wrap=document.getElementById('live-preview');
wrap.innerHTML=''; // 기존 img/에러 제거 → 이전 연결 종료(서버 ffmpeg 정리)
const err=document.createElement('div');err.className='live-err';err.style.display='none';
const img=document.createElement('img');
img.alt='RTSP live';
img.onerror=()=>{
img.style.display='none';
err.textContent='RTSP 영상을 불러오지 못했습니다. NVR의 RTSP 서비스 활성화 여부와 설정 탭의 RTSP 포트·경로 템플릿·계정을 확인하세요.';
err.style.display='block';
};
wrap.appendChild(img);
wrap.appendChild(err);
liveImg=img;liveErrEl=err;
img.src='/api/nvr/stream/'+ch+'?stream='+stream+'&_='+Date.now();
}
function stopManual(){
const wrap=document.getElementById('live-preview');
if(liveImg){killImg(liveImg,liveManualCh,liveManualStream);} // 연결 종료 + 서버 즉시-컷
liveImg=null;liveErrEl=null;liveManualCh=null;
wrap.innerHTML='<div class="empty">정지됨. ▶ 재생으로 다시 볼 수 있습니다.</div>';
}
let recordsLoadedOnce=false;
function switchTab(t){
['chn','live','evt','set'].forEach(k=>{
document.getElementById('tab-'+k).classList.toggle('active',k===t);
document.getElementById('pane-'+k).classList.toggle('active',k===t);
});
if(t==='set') loadConfigForm();
// 라이브 탭 최초 진입시 오늘 녹화 목록 자동 조회(이후엔 조회 버튼으로만).
if(t==='live' && !recordsLoadedOnce){ recordsLoadedOnce=true; loadRecords(); }
// 채널 탭을 벗어나면 5분할 스트림 정지(서버/NVR 부하 방지), 들어오면 자동 재생
if(t!=='chn') stopQuad();
else ensureQuadAutoPlay();
}
// ── 채널 이름/위치 라벨 ──
// NVR은 채널 이름을 모델명으로만 줌("5323-W-Q") — 카메라를 위치("현관","거실")로
// 구분하고 싶을 때 편집. 서버 nvr.channelLabels에 채널ID별로 저장(POST로 갱신).
let lastChannels=[];
function chnDisplayName(c){return c.label||c.model||('채널 '+c.id);}
// NVR이 online=false로 보고해도 RTSP 패스스루가 되는 상태가 있다(예: "Resolution too big" —
// Xiongmai NVR + 3rd파티 카메라에서 흔함). 이런 채널도 스트림 시도는 하게 한다.
// 진짜 죽은 채널이면 각 셀의 img.onerror가 "영상 없음"으로 처리.
function chnStreamable(c){return c.online || /resolution too big/i.test(c.statusText||'');}
async function editChannelLabel(channelId){
const c=lastChannels.find(x=>x.id===channelId);
const val=prompt('채널 #'+channelId+' 이름/위치 (비워두면 모델명 표시)',c?c.label||'':'');
if(val===null) return; // 취소
try{
const r=await fetch('/api/nvr/channels/'+channelId+'/label',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({label:val})});
const d=await r.json();
if(!r.ok) throw new Error(d.error||'저장 실패');
showToast('저장됨');
loadChannels();
}catch(e){showToast('오류: '+e.message,true);}
}
// ── 채널별 화질 강제 ──
// 신호 약한 무선 카메라는 메인(고화질)이 계속 끊기고 서브(저화질)만 안정적일 때
// 그 채널만 항상 서브를 쓰도록 고정(2026-07-21, 채널3 사례). 5분할·라이브 모달 둘 다 적용.
async function toggleChannelStream(channelId){
const forced=chnStreamOverride(channelId)===1;
try{
const r=await fetch('/api/nvr/channels/'+channelId+'/stream',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({stream:forced?null:1})});
const d=await r.json();
if(!r.ok) throw new Error(d.error||'저장 실패');
showToast(forced?'화질 고정 해제됨':'저화질(서브) 고정됨');
loadChannels();
}catch(e){showToast('오류: '+e.message,true);}
}
async function loadChannels(){
const devEl=document.getElementById('dev-info');
const gridEl=document.getElementById('chn-grid');
let channels=[];
let nvrError=null;
// 채널 목록은 독립 카메라(메인 NVR과 별개)도 포함하므로, 메인 NVR이 응답 없어도
// 이 요청 자체는 성공한다(서버가 nvrError 필드로 알려줌) — 그래서 상태 조회와 분리.
try{
const chnR=await fetch('/api/nvr/channels',{headers:authH()});
const chnD=await chnR.json();
if(!chnR.ok) throw new Error(chnD.error||'채널 조회 실패');
channels=(chnD.channels||[]).filter(c=>c.enabled);
nvrError=chnD.nvrError||null;
}catch(e){
devEl.textContent='오류: '+e.message;
gridEl.innerHTML=`<div class="empty">채널 정보를 불러오지 못했습니다. 설정 탭에서 NVR 연결정보를 확인하세요.</div>`;
quadChannels=[];renderQuad();
return;
}
try{
const statusR=await fetch('/api/nvr/status',{headers:authH()});
const statusD=await statusR.json();
if(!statusR.ok) throw new Error(statusD.error||'상태 조회 실패');
const dev=statusD.device||{};
devEl.innerHTML=`<b>${escHtml(dev.DeviceName||'NVR')}</b> (${escHtml(dev.DeviceModel||'')}) · 펌웨어 ${escHtml(dev.FWVersion||'')} · 최대 ${escHtml(dev.MAX_CHN||'?')}채널`;
}catch(e){
// 메인 NVR 상태 조회는 부가 정보 — 실패해도 독립 카메라 목록/라이브는 그대로 보여준다.
devEl.textContent=nvrError?`메인 NVR 응답 없음(${nvrError}) — 독립 카메라만 표시됩니다`:'오류: '+e.message;
}
lastChannels=channels;
if(!channels.length){gridEl.innerHTML='<div class="empty">활성화된 채널이 없습니다.</div>';return;}
gridEl.innerHTML=channels.map(c=>`
<div class="chn-card ${c.online?'':'offline'}">
<div class="chn-top">
<span class="chn-dot ${c.online?'on':''}"></span>
<span class="chn-name">${escHtml(chnDisplayName(c))}</span>
<button class="chn-edit" onclick="editChannelLabel(${c.id})" title="이름/위치 편집">✎</button>
${c.standalone?`<button class="chn-edit" onclick="deleteExtraCamera(${c.id})" title="카메라 삭제">🗑</button>`:''}
<span class="chn-id">#${c.id}</span>
</div>
${c.label?`<div class="chn-row"><span>모델</span><span>${escHtml(c.model)}</span></div>`:''}
<div class="chn-row"><span>상태</span><span>${escHtml(c.statusText)}</span></div>
<div class="chn-row"><span>IP</span><span>${escHtml(c.ip)}</span></div>
${c.wifiSignal!=null?`<div class="chn-row"><span>WiFi 신호</span><span>${c.wifiSignal}%</span></div>`:''}
<div class="chn-badges">
${c.recording?'<span class="badge rec">● 녹화중</span>':''}
${c.motionEvent?'<span class="badge warn">움직임 감지</span>':''}
${c.bodyEvent?'<span class="badge warn">사람 감지</span>':''}
${c.faceEvent?'<span class="badge warn">얼굴 감지</span>':''}
${c.preferredStream===1?'<span class="badge warn">저화질 고정</span>':''}
</div>
<button class="chn-live" data-name="${escHtml(chnDisplayName(c))}" onclick="openLive(${c.id},this)" ${chnStreamable(c)?'':'disabled'}>▶ 라이브</button>
${/resolution too big/i.test(c.statusText||'')?'':`<button class="hdr-btn" style="margin-top:6px" onclick="toggleChannelStream(${c.id})">${c.preferredStream===1?'화질 고정 해제':'저화질(서브) 고정'}</button>`}
${c.standalone?`<button class="hdr-btn" style="margin-top:6px" onclick="toggleExtraCameraRotate(${c.id},${!c.rotate180})">${c.rotate180?'상하반전 해제':'상하반전(180도)'}</button>`:''}
</div>`).join('');
// 6분할: 스트림 가능한 채널(최대 6, 독립 카메라 포함)로 셀 구성. 빈 칸은 placeholder.
// online=false여도 패스스루 되는 상태("Resolution too big" 등)는 포함 — chnStreamable() 참고.
quadChannels=channels.filter(chnStreamable).slice(0,6);
renderQuad();
}
async function addExtraCamera(){
const label=prompt('카메라 이름(예: 마당, 현관)', '카메라');
if(label==null) return;
const host=prompt('카메라 IP 주소', '192.168.0.2');
if(!host) return;
const rtspPort=prompt('RTSP 포트', '554');
const username=prompt('RTSP 사용자명', 'camera');
const password=prompt('RTSP 비밀번호', '');
const rtspPath=prompt('RTSP 경로(메인 스트림)', '/stream1');
const ptz=confirm('이 카메라가 팬/틸트(PTZ)를 지원하나요?');
const onvifPort=ptz?(prompt('ONVIF 포트', '2020')||'2020'):'80';
try{
const r=await fetch('/api/nvr/extra-cameras',{method:'POST',headers:authH({'Content-Type':'application/json'}),
body:JSON.stringify({label,host,rtspPort:Number(rtspPort)||554,username,password,rtspPath,ptz,onvifPort:Number(onvifPort)||80})});
const d=await r.json();
if(!r.ok||!d.success) throw new Error(d.error||'추가 실패');
loadChannels();
}catch(e){ alert('오류: '+e.message); }
}
async function deleteExtraCamera(id){
if(!confirm('이 카메라를 삭제할까요?')) return;
try{
await fetch('/api/nvr/extra-cameras/'+id,{method:'DELETE',headers:authH()});
loadChannels();
}catch(e){ alert('오류: '+e.message); }
}
async function toggleExtraCameraRotate(id,rotate180){
try{
await fetch('/api/nvr/extra-cameras/'+id+'/rotate',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({rotate180})});
loadChannels();
}catch(e){ alert('오류: '+e.message); }
}
// ── 5분할 라이브 ──
let quadChannels=[];let quadPlaying=false;
function isChnActive(){return document.getElementById('pane-chn').classList.contains('active');}
function renderQuad(){
const grid=document.getElementById('quad-grid');
// grid.innerHTML 교체 전 반드시 기존 스트림을 명시적으로 끊는다 — innerHTML=로 갈아
// 엎으면 <img> 요소가 stopChannel() 없이 그냥 사라져 서버 ffmpeg가 좀비로 남음
// (↺새로고침·설정저장·채널목록 변경마다 renderQuad가 불려서 재생중에 호출되면 누적).
stopQuadStreams();
const cells=[];
for(let i=0;i<6;i++){
const c=quadChannels[i];
if(c){
cells.push(`<div class="quad-cell" data-id="${c.id}" data-name="${escHtml(chnDisplayName(c))}" onclick="openLive(${c.id},this)">
<span class="lbl">${escHtml(chnDisplayName(c))} · #${c.id}</span>
<div class="ph">…</div>
</div>`);
}else{
cells.push(`<div class="quad-cell empty-slot"><div class="ph">빈 슬롯</div></div>`);
}
}
grid.innerHTML=cells.join('');
// 템플릿에 {channel}/{channel0} 치환자가 없으면 모든 칸이 같은 카메라로 보임 — 경고
fetch('/api/nvr/config',{headers:authH()}).then(r=>r.json()).then(d=>{
const t=d.rtspPathTemplate||'';
const hasCh=t.includes('{channel}')||t.includes('{channel0}');
const hint=document.getElementById('quad-hint');
if(!hasCh && d.rtspPort) hint.textContent='⚠ 경로 템플릿에 {channel} 없음 — 모든 칸이 같은 카메라. /ch{channel}_{stream}.264 권장';
else hint.textContent=quadChannels.length+'개 카메라 · '+(document.getElementById('quad-main').checked?'메인(고화질)':'서브(저화질)');
}).catch(()=>{});
// 채널 탭이 보이는 중이면 자동 재생(모두 화면). 다른 탭이면 정지 상태 유지.
if(isChnActive() && quadChannels.length) startQuad();
}
function startQuad(){
quadPlaying=true;
document.getElementById('quad-play-btn').textContent='■ 정지';
startQuadStreams();
}
function ensureQuadAutoPlay(){
// 채널 탭 진입/렌더 시 자동 재생. 이미 재생중이면 유지.
if(isChnActive() && quadChannels.length && !quadPlaying) startQuad();
}
function toggleQuad(){
if(quadPlaying){stopQuad();}
else{startQuad();}
}
function startQuadStreams(){
const globalStream=document.getElementById('quad-main').checked?0:1; // 기본 서브(저부하), 체크시 메인
document.querySelectorAll('#quad-grid .quad-cell:not(.empty-slot)').forEach(cell=>{
const id=cell.dataset.id;
const override=chnStreamOverride(Number(id));
const stream=override==null?globalStream:override; // 채널별 강제 화질이 있으면 전역 설정보다 우선
let img=cell.querySelector('img');
if(!img){
img=document.createElement('img');img.alt='RTSP';
img.onerror=()=>{img.style.display='none';const ph=document.createElement('div');ph.className='ph';ph.style.color='#ef4444';ph.textContent='영상 없음';cell.appendChild(ph);};
cell.insertBefore(img,cell.firstChild);
}
// 재개시 이전 "영상 없음" placeholder 제거(에러 .ph가 남아있으면 겹침).
cell.querySelectorAll('.ph').forEach(e=>e.remove());
img.style.display='block';
img.dataset.streamQ=stream; // stop시 어느 stream(main/sub)이었는지 알아야 정확히 컷
img.src='/api/nvr/stream/'+id+'?stream='+stream+'&_='+Date.now();
});
}
function stopQuadStreams(){
// img 요소 제거는 브라우저단 정리(느리거나 keep-alive로 안 끊길 수 있음) — 동시에
// 서버에 명시적 즉시-컷을 요청해 ffmpeg를 확실히 죽인다(4채널 동시 전환이 잦은 경로라
// 좀비 누적 위험이 가장 큼: 카메라 넘기기·품질 토글·탭 전환마다 여기를 지난다).
document.querySelectorAll('#quad-grid .quad-cell img').forEach(img=>{
img.onerror=null;
const cell=img.closest('.quad-cell');
const id=cell?cell.dataset.id:null;
if(id!=null) stopChannel(id,img.dataset.streamQ||0);
img.remove();
});
}
function stopQuad(){ // 탭 이탈시 호출
if(quadPlaying){
quadPlaying=false;
document.getElementById('quad-play-btn').textContent='▶ 재생';
}
stopQuadStreams();
}
async function loadEvents(){
const resEl=document.getElementById('evt-result');
resEl.innerHTML='<div class="empty">조회 중...</div>';
const beginRaw=document.getElementById('evt-begin').value;
const endRaw=document.getElementById('evt-end').value;
const toNvrTime=v=>v?v.replace('T',' ')+':00':undefined;
try{
const r=await fetch('/api/nvr/events',{
method:'POST',
headers:authH({'Content-Type':'application/json'}),
body:JSON.stringify({beginTime:toNvrTime(beginRaw),endTime:toNvrTime(endRaw)}),
});
const d=await r.json();
if(!r.ok) throw new Error(d.error||'조회 실패');
const list=d.LogInfo||d.List||d.Result||[];
if(!Array.isArray(list)||!list.length){
resEl.innerHTML=`<div class="empty">${d.RetDetail?escHtml(d.RetDetail):'해당 기간 로그가 없습니다.'} (검색 ${d.SearchCnt||0}건)</div>`;
return;
}
const keys=Object.keys(list[0]);
resEl.innerHTML=`<table class="evt-table"><thead><tr>${keys.map(k=>`<th>${escHtml(k)}</th>`).join('')}</tr></thead>
<tbody>${list.map(row=>`<tr>${keys.map(k=>`<td>${escHtml(row[k])}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
}catch(e){
resEl.innerHTML=`<div class="empty">오류: ${escHtml(e.message)}</div>`;
}
}
// ── 녹화 목록 조회 (재생/다운로드는 이 NVR 웹으로는 불가 — 서버 라우트 주석 참고) ──
function fmtRecTime(unixSec){
const d=new Date(unixSec*1000);
const pad=n=>String(n).padStart(2,'0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function fmtRecSize(bytes){
if(bytes>=1024*1024*1024) return (bytes/1024/1024/1024).toFixed(1)+'GB';
if(bytes>=1024*1024) return (bytes/1024/1024).toFixed(1)+'MB';
return Math.round(bytes/1024)+'KB';
}
async function loadRecords(){
const listEl=document.getElementById('rec-list');
const countEl=document.getElementById('rec-count');
const dateEl=document.getElementById('rec-date');
if(!dateEl.value){ const t=new Date(); dateEl.value=t.toISOString().slice(0,10); }
listEl.innerHTML='<div class="empty">조회 중...</div>';
countEl.textContent='';
try{
const r=await fetch('/api/nvr/records',{
method:'POST',
headers:authH({'Content-Type':'application/json'}),
body:JSON.stringify({date:dateEl.value}),
});
const d=await r.json();
if(!r.ok) throw new Error(d.error||'조회 실패');
const records=(d.records||[]).slice().sort((a,b)=>b.timeStart-a.timeStart);
countEl.textContent=`총 ${d.total||records.length}건`;
if(!records.length){
listEl.innerHTML='<div class="empty">해당 날짜에 녹화가 없습니다.</div>';
return;
}
listEl.innerHTML=`<table class="rec-table"><thead><tr>
<th>채널</th><th>시작</th><th>종료</th><th>종류</th><th>용량</th>
</tr></thead><tbody>${records.map(rec=>`<tr>
<td>#${rec.channel}</td>
<td>${fmtRecTime(rec.timeStart)}</td>
<td>${fmtRecTime(rec.timeEnd)}</td>
<td>${escHtml(rec.type)}</td>
<td>${fmtRecSize(rec.sizeBytes)}</td>
</tr>`).join('')}</tbody></table>`;
}catch(e){
listEl.innerHTML=`<div class="empty">오류: ${escHtml(e.message)}</div>`;
}
}
async function loadConfigForm(){
try{
const r=await fetch('/api/nvr/config',{headers:authH()});
const d=await r.json();
if(!r.ok) return;
document.getElementById('set-host').value=d.host||'';
document.getElementById('set-port').value=d.port||80;
document.getElementById('set-rtspport').value=d.rtspPort||554;
document.getElementById('set-rtsp-path').value=d.rtspPathTemplate||'';
document.getElementById('set-username').value=d.username||'admin';
document.getElementById('set-pw-hint').textContent=d.hasPassword?'(설정됨 — 변경할 때만 입력)':'(미설정)';
// 브라우저 자동완성이 로그인 비밀번호 등을 몰래 채워넣는 걸 막기 위해 매번 강제로 비움
document.getElementById('set-password').value='';
}catch{}
}
async function saveConfig(ev){
ev.preventDefault();
const body={
host:document.getElementById('set-host').value.trim(),
port:Number(document.getElementById('set-port').value)||80,
rtspPort:Number(document.getElementById('set-rtspport').value)||554,
rtspPathTemplate:document.getElementById('set-rtsp-path').value.trim(),
username:document.getElementById('set-username').value.trim()||'admin',
password:document.getElementById('set-password').value,
};
try{
const r=await fetch('/api/nvr/config',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
const d=await r.json();
if(!r.ok) throw new Error(d.error||'저장 실패');
showToast('저장됨');
document.getElementById('set-password').value='';
loadConfigForm();
loadChannels();
}catch(e){
showToast('오류: '+e.message,true);
}
}
// 탭 닫기/새로고침/다른 페이지 이동 시 재생중이던 모든 채널을 서버에 명시적으로 끊음.
// res 'close' 이벤트가 언로드 타이밍에 안 오는 경우가 있어(브라우저가 이미 죽인 커넥션도
// 있고, 안 죽인 것도 있음) 안전망으로 전부 stop 호출(keepalive:true라 언로드 중에도 감).
window.addEventListener('pagehide',()=>{
document.querySelectorAll('#quad-grid .quad-cell img').forEach(img=>{
const cell=img.closest('.quad-cell');
const id=cell?cell.dataset.id:null;
if(id!=null) stopChannel(id,img.dataset.streamQ||0);
});
if(currentLiveChannel!=null) stopChannel(currentLiveChannel,0);
if(liveManualCh!=null) stopChannel(liveManualCh,liveManualStream);
});
// ── Init ──
(()=>{
const now=new Date();
const weekAgo=new Date(now.getTime()-7*24*3600*1000);
const toLocal=d=>{
const pad=n=>String(n).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
document.getElementById('evt-begin').value=toLocal(weekAgo);
document.getElementById('evt-end').value=toLocal(now);
loadChannels();
})();
</script>
</body>
</html>