- nvr-app.html: go2rtc WS URL을 페이지 오리진 기준 상대경로로 (하드코딩된 ws://LAN IP가 HTTPS 페이지에서 mixed-content로 막히던 문제) - k2-app.html: 카메라 재연결 시 오래된 연결의 실패 콜백이 새 연결 상태를 덮어쓰던 레이스 수정 - routes-k2.ts: K2 시그널링 fetch에 타임아웃 추가(프린터 응답없음 시 무한대기 방지) - nvr.ts/routes-comfyui.ts/routes-android.ts에 중복돼있던 raw WebSocket 업그레이드 프록시(connect→pipe→cleanup)를 ws-proxy-util.ts의 공통 헬퍼로 통합
798 lines
45 KiB
HTML
798 lines
45 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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을 다시 씀.
|
|
// 반드시 현재 페이지 오리진 기준 상대경로로 만들어야 함 — LAN IP를 ws://로 하드코딩하면
|
|
// 이 페이지를 https://ai.applecherry.net으로 열었을 때 mixed-content로 막힘(K2 카메라와 동일 패턴).
|
|
const GO2RTC_WS=(location.protocol==='https:'?'wss://':'ws://')+location.host+'/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>
|