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
This commit is contained in:
@@ -388,7 +388,8 @@
|
||||
"password": "vault:nvr.extraCameras.1000.password",
|
||||
"rtspPath": "/stream1",
|
||||
"ptz": true,
|
||||
"onvifPort": 2020
|
||||
"onvifPort": 2020,
|
||||
"go2rtcSrc": "cam"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Express, Request, Response } from 'express';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
import { getConfig } from '../../config/config.js';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { Cam: OnvifCam } = require('onvif');
|
||||
@@ -52,6 +54,7 @@ interface ExtraCamera {
|
||||
rotate180: boolean;
|
||||
ptz: boolean;
|
||||
onvifPort: number;
|
||||
go2rtcSrc: string;
|
||||
}
|
||||
export function getExtraCameras(): ExtraCamera[] {
|
||||
const cm = getConfig();
|
||||
@@ -73,6 +76,10 @@ export function getExtraCameras(): ExtraCamera[] {
|
||||
// MC510은 2020번, RTSP/HTTP 포트와 별개). WS-Security 인증은 RTSP와 같은 계정 사용(2026-07-23).
|
||||
ptz: c.ptz === true,
|
||||
onvifPort: Number(c.onvifPort) || 80,
|
||||
// go2rtc(클로서버:1985)에 이 카메라가 별도 릴레이로 등록돼 있으면 스트림 이름(예: "cam").
|
||||
// 있으면 프런트에서 ffmpeg-MJPEG 대신 go2rtc의 video-stream(WebRTC→MSE→MJPEG 자동폴백)을
|
||||
// 써서 대역폭을 크게 줄인다(2026-09-18, 프린터실 카메라 원격뷰 대역폭 이슈).
|
||||
go2rtcSrc: String(c.go2rtcSrc || ''),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -313,6 +320,8 @@ export function registerNvrRoutes(app: Express): void {
|
||||
charging: false,
|
||||
standalone: true,
|
||||
rotate180: c.rotate180,
|
||||
ptz: c.ptz,
|
||||
go2rtcSrc: c.go2rtcSrc || undefined,
|
||||
}));
|
||||
channels.push(...extra);
|
||||
res.json({ channels, ...(nvrError ? { nvrError } : {}) });
|
||||
@@ -874,3 +883,54 @@ export function registerNvrRoutes(app: Express): void {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const GO2RTC_HOST = '127.0.0.1';
|
||||
const GO2RTC_PORT = 1985;
|
||||
const GO2RTC_WS_PREFIX = '/api/nvr/go2rtc-ws';
|
||||
|
||||
// go2rtc의 WebSocket 엔드포인트(/api/ws)는 Origin이 자기 자신(호스트:포트)과 다르면
|
||||
// "request origin not allowed by Upgrader.CheckOrigin"로 거부한다 — 브라우저가 이
|
||||
// 게이트웨이(18789) 페이지에서 곧바로 go2rtc(1985)에 붙으면 항상 걸림. 그래서 같은
|
||||
// 오리진(게이트웨이)으로 먼저 붙게 하고, 여기서 백엔드로 넘길 때 Host/Origin을
|
||||
// go2rtc 자신의 주소로 다시 써서 그 검사를 통과시킨다(2026-09-18, video-stream.js
|
||||
// WebRTC/MSE 도입 때 발견).
|
||||
export function attachGo2rtcWsProxy(server: http.Server, getSessionUserFromUpgradeReq: (req: http.IncomingMessage) => any): void {
|
||||
server.on('upgrade', (req, clientSocket, head) => {
|
||||
const url = req.url || '';
|
||||
if (!url.startsWith(GO2RTC_WS_PREFIX)) return; // 우리 것 아니면 다른 upgrade 리스너에게 넘김
|
||||
|
||||
const session = getSessionUserFromUpgradeReq(req);
|
||||
if (!session) {
|
||||
clientSocket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
clientSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const backendSocket = net.connect({ host: GO2RTC_HOST, port: GO2RTC_PORT }, () => {
|
||||
const qs = url.includes('?') ? url.slice(url.indexOf('?')) : '';
|
||||
const backendHost = `${GO2RTC_HOST}:${GO2RTC_PORT}`;
|
||||
const headerLines = [`${req.method} /api/ws${qs} HTTP/1.1`];
|
||||
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
||||
const name = req.rawHeaders[i];
|
||||
if (/^host$/i.test(name)) { headerLines.push(`Host: ${backendHost}`); continue; }
|
||||
if (/^origin$/i.test(name)) { headerLines.push(`Origin: http://${backendHost}`); continue; }
|
||||
headerLines.push(`${name}: ${req.rawHeaders[i + 1]}`);
|
||||
}
|
||||
backendSocket.write(headerLines.join('\r\n') + '\r\n\r\n');
|
||||
if (head && head.length) backendSocket.write(head);
|
||||
|
||||
clientSocket.pipe(backendSocket);
|
||||
backendSocket.pipe(clientSocket);
|
||||
|
||||
const cleanup = () => {
|
||||
try { clientSocket.destroy(); } catch {}
|
||||
try { backendSocket.destroy(); } catch {}
|
||||
};
|
||||
clientSocket.on('close', cleanup);
|
||||
clientSocket.on('error', cleanup);
|
||||
backendSocket.on('close', cleanup);
|
||||
backendSocket.on('error', cleanup);
|
||||
});
|
||||
backendSocket.on('error', () => { try { clientSocket.destroy(); } catch {} });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import express, { Express, Request, Response } from 'express';
|
||||
|
||||
// K2 콤보(Creality) 프린터 내장 카메라는 자체 WebRTC 시그널링을 평문 HTTP로만
|
||||
// 서빙한다(:8000/call/webrtc_local). 홈클로 자체는 https://ai.applecherry.net로
|
||||
// 서빙되므로, 그 HTTPS 페이지에서 이 HTTP 엔드포인트로 직접 fetch(POST)하면
|
||||
// 브라우저의 mixed-content 정책에 막혀 요청 자체가 안 나간다(주소창 "주의 요함" +
|
||||
// 카메라 화면 검은 채로 멈춤, 2026-09-18 발견). 그래서 이 엔드포인트를 우리 서버가
|
||||
// 대신 호출해주는 얇은 프록시로 우회한다 — 서버-서버 호출은 스킴 제약이 없다.
|
||||
const K2_CAM_HOST = '192.168.0.114';
|
||||
const K2_CAM_PORT = 8000;
|
||||
|
||||
export function registerK2Routes(app: Express): void {
|
||||
// 클라이언트가 'text/plain'으로 보내는데 서버 전역엔 json/urlencoded 파서만 등록돼
|
||||
// 있어(server.ts) req.body가 안 채워짐 — 이 라우트에서만 원본 바디를 텍스트로 받는다.
|
||||
app.post('/api/k2/webrtc-signal', express.text({ type: () => true, limit: '2mb' }), async (req: Request, res: Response) => {
|
||||
const user = (req as any).user;
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
try {
|
||||
const body = typeof req.body === 'string' ? req.body : String(req.body ?? '');
|
||||
const r = await fetch(`http://${K2_CAM_HOST}:${K2_CAM_PORT}/call/webrtc_local`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body,
|
||||
});
|
||||
const text = await r.text();
|
||||
res.status(r.status).type('text/plain').send(text);
|
||||
} catch (e: any) {
|
||||
res.status(502).json({ error: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -131,7 +131,8 @@ import { summarizeSkillForApi } from '../tools/skills.js';
|
||||
import { getToolRegistry } from '../tools/registry.js';
|
||||
import { registerSettingsRoutes } from './routes/settings.js';
|
||||
import { registerImagegenRoutes } from './routes/imagegen.js';
|
||||
import { registerNvrRoutes } from './routes/nvr.js';
|
||||
import { registerNvrRoutes, attachGo2rtcWsProxy } from './routes/nvr.js';
|
||||
import { registerK2Routes } from './routes/routes-k2.js';
|
||||
import {
|
||||
browserOpen,
|
||||
browserSnapshot,
|
||||
@@ -3561,6 +3562,7 @@ async function runTaskHeartbeat(): Promise<void> {
|
||||
registerSettingsRoutes(app);
|
||||
registerImagegenRoutes(app);
|
||||
registerNvrRoutes(app);
|
||||
registerK2Routes(app);
|
||||
|
||||
// Fetch available Ollama models (proxies Ollama /api/tags), with vision capability flag
|
||||
app.get('/api/ollama/models', async (_req, res) => {
|
||||
@@ -4646,6 +4648,7 @@ attachAndroidWsProxy(server, getSessionUser);
|
||||
attachAndroidConsoleWsProxy(server, getSessionUser);
|
||||
attachComfyUIWsProxy(server, getSessionUserFromUpgradeReq);
|
||||
attachVoiceRealtimeWsProxy(server, getSessionUserFromUpgradeReq);
|
||||
attachGo2rtcWsProxy(server, getSessionUserFromUpgradeReq);
|
||||
wss.on('error', (err: any) => {
|
||||
if (err?.code === 'EADDRINUSE') {
|
||||
console.error(`[Gateway] Port ${HOST}:${PORT} is already in use.`);
|
||||
|
||||
@@ -93,8 +93,6 @@
|
||||
<div class="mode-dropdown-menu" id="mode-dropdown-menu-home">
|
||||
<button onclick="window.open('/html/nvr-app.html','_blank')">📹 홈CCTV</button>
|
||||
<button onclick="window.open('/html/home-assistant-app.html','_blank')">🏠 HA</button>
|
||||
<button onclick="window.open('/html/printer-app.html','_blank')">🖨️ 3D프린터</button>
|
||||
<button onclick="window.open('/html/milling-app.html','_blank')">⚙️ CNC 밀링</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="mode-btn" onclick="window.open('/dental-agent.html?t='+Date.now(),'_blank')" title="치과 사전 이미지 에이전트">🦷 치과</button>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🖨️ K2 콤보</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:#f97316;}
|
||||
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;text-decoration:none;display:inline-flex;align-items:center;}
|
||||
.hdr-btn:hover{border-color:var(--brand);color:var(--brand);}
|
||||
|
||||
.body-wrap{flex:1;min-height:0;display:flex;flex-direction:column;}
|
||||
.cam-bar{flex-shrink:0;background:#000;display:flex;justify-content:center;position:relative;max-height:32vh;}
|
||||
.cam-bar video{max-width:100%;max-height:32vh;display:block;background:#000;}
|
||||
.cam-status{position:absolute;top:6px;right:10px;font-size:11px;color:#9ca3af;background:rgba(0,0,0,.5);padding:2px 8px;border-radius:6px;}
|
||||
.cam-empty{color:var(--muted);font-size:12px;padding:16px;text-align:center;}
|
||||
|
||||
.frame-wrap{flex:1;min-height:0;position:relative;}
|
||||
.frame-wrap iframe{width:100%;height:100%;border:none;display:block;}
|
||||
.err{display:none;position:absolute;inset:0;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:var(--bg);color:var(--muted);font-size:13px;text-align:center;padding:20px;}
|
||||
.err.show{display:flex;}
|
||||
.err a{color:var(--brand);}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="hdr">
|
||||
<a href="/" style="text-decoration:none;font-size:13px;color:var(--muted)">← 홈</a>
|
||||
<h1>🖨️ K2 콤보</h1>
|
||||
<span class="hdr-sp"></span>
|
||||
<button class="hdr-btn" onclick="reconnectCam()">📷 카메라 재연결</button>
|
||||
<button class="hdr-btn" onclick="reloadFrame()">↺ 새로고침</button>
|
||||
<a class="hdr-btn" href="http://192.168.0.114:4408/" target="_blank">↗ 새 탭</a>
|
||||
<button class="hdr-btn" onclick="toggleTheme()">🌙</button>
|
||||
</header>
|
||||
|
||||
<div class="body-wrap">
|
||||
<div class="cam-bar" id="cam-bar">
|
||||
<video id="k2-cam" autoplay muted playsinline></video>
|
||||
<span class="cam-status" id="cam-status">연결 중...</span>
|
||||
</div>
|
||||
<div class="frame-wrap">
|
||||
<iframe id="k2-frame" src="http://192.168.0.114:4408/" title="Fluidd"></iframe>
|
||||
<div class="err" id="k2-err">
|
||||
<div>Fluidd를 불러오지 못했습니다 (K2 콤보, 192.168.0.114:4408).</div>
|
||||
<div><a href="http://192.168.0.114:4408/" target="_blank">새 탭에서 직접 열기 ↗</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
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 reloadFrame(){const f=document.getElementById('k2-frame');f.src=f.src;}
|
||||
let loaded=false;
|
||||
document.getElementById('k2-frame').addEventListener('load',()=>{loaded=true;});
|
||||
setTimeout(()=>{ if(!loaded) document.getElementById('k2-err').classList.add('show'); },8000);
|
||||
|
||||
// ── K2 콤보 내장 카메라(자체 WebRTC 시그널링, 프린터 펌웨어가 직접 서빙) ──
|
||||
// 프린터 자체는 :8000/call/webrtc_local 로 SDP offer를 POST하면 answer를 돌려주는
|
||||
// 평문 HTTP 시그널링(Moonraker webcam 목록엔 안 잡힘 — 크리얼리티 자체 프로토콜).
|
||||
// 홈클로는 https://ai.applecherry.net 로 서빙되므로 브라우저에서 직접 http://로
|
||||
// fetch(POST)하면 mixed-content로 막힘("주의 요함" + 카메라 검은 화면, 2026-09-18
|
||||
// 발견) — 그래서 게이트웨이의 /api/k2/webrtc-signal(같은 오리진, HTTPS)을 거쳐
|
||||
// 서버가 대신 그 요청을 해준다.
|
||||
function authH(){const t=(function(){try{return sessionStorage.getItem('smallclaw_token')||localStorage.getItem('smallclaw_token')||'';}catch{return '';}})();return t?{'Authorization':'Bearer '+t}:{};}
|
||||
let camPC=null;
|
||||
function setStatus(t){const s=document.getElementById('cam-status'); if(s) s.textContent=t;}
|
||||
function connectCam(){
|
||||
if(camPC){ try{camPC.close();}catch(e){} }
|
||||
setStatus('연결 중...');
|
||||
// 같은 LAN 안이라 STUN 없이 host candidate만으로 충분 — 외부 STUN 서버 왕복을
|
||||
// 기다릴 필요가 없고, 그 서버가 막혀있는 네트워크에서도 즉시 연결된다.
|
||||
const pc=new RTCPeerConnection({iceServers:[]});
|
||||
camPC=pc;
|
||||
pc.ontrack=(ev)=>{
|
||||
const v=document.getElementById('k2-cam');
|
||||
v.srcObject=ev.streams[0];
|
||||
setStatus('실시간');
|
||||
};
|
||||
pc.oniceconnectionstatechange=()=>{
|
||||
if(pc===camPC && (pc.iceConnectionState==='failed'||pc.iceConnectionState==='disconnected')){
|
||||
setStatus('끊김');
|
||||
}
|
||||
};
|
||||
pc.onicecandidate=(ev)=>{
|
||||
if(ev.candidate===null){
|
||||
fetch('/api/k2/webrtc-signal',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'text/plain', ...authH()},
|
||||
body:btoa(JSON.stringify({type:'offer',sdp:pc.localDescription.sdp})),
|
||||
}).then(r=>r.text()).then(txt=>{
|
||||
const res=JSON.parse(atob(txt));
|
||||
if(res.type==='answer' && pc===camPC){
|
||||
pc.setRemoteDescription(new RTCSessionDescription(res));
|
||||
}
|
||||
}).catch(()=>{ setStatus('연결 실패'); });
|
||||
}
|
||||
};
|
||||
pc.addTransceiver('video',{direction:'sendrecv'});
|
||||
pc.createOffer().then(d=>pc.setLocalDescription(d)).catch(()=>{ setStatus('연결 실패'); });
|
||||
}
|
||||
function reconnectCam(){ connectCam(); }
|
||||
connectCam();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -117,7 +117,9 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
.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">
|
||||
@@ -242,6 +244,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
<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">
|
||||
@@ -290,6 +293,10 @@ function killImg(img,channelId,streamQ){ // multipart fetch 중단 + 서버 즉
|
||||
}
|
||||
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){
|
||||
@@ -322,14 +329,29 @@ function openLive(channelId,btn){
|
||||
const titleEl=document.getElementById('live-title');
|
||||
errEl.style.display='none';errEl.textContent='';
|
||||
titleEl.textContent=(btn?btn.getAttribute('data-name')||('채널 '+channelId):('채널 '+channelId))+(currentLiveStream?' (저화질 고정)':'');
|
||||
img.style.display='block';
|
||||
img.onerror=()=>{ // 서버가 502 JSON을 보내거나 프레임이 안 오면 img가 깨짐
|
||||
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';
|
||||
errEl.textContent='RTSP 영상을 불러오지 못했습니다. NVR 설정(호스트·계정·비밀번호)과 RTSP 경로를 확인하세요.';
|
||||
errEl.style.display='block';
|
||||
};
|
||||
// 캐시 방지 위해 채널 id + 타임스탵 쿼리(같은 src 재사용시 onerror 안 트리는 브라우저 대비)
|
||||
img.src='/api/nvr/stream/'+channelId+(currentLiveStream?'?stream=1':'');
|
||||
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(){
|
||||
@@ -338,7 +360,13 @@ function closeLive(){
|
||||
currentLiveChannel=null;
|
||||
const modal=document.getElementById('live-modal');
|
||||
const img=document.getElementById('live-img');
|
||||
killImg(img,closingChannel,closingStream); // 연결 종료 + 서버 즉시-컷
|
||||
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(); }
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🖨️ 3D 프린터</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:#f97316;}
|
||||
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;text-decoration:none;display:inline-flex;align-items:center;}
|
||||
.hdr-btn:hover{border-color:var(--brand);color:var(--brand);}
|
||||
|
||||
.frame-wrap{flex:1;min-height:0;position:relative;}
|
||||
.frame-wrap iframe{width:100%;height:100%;border:none;display:block;}
|
||||
.err{display:none;position:absolute;inset:0;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:var(--bg);color:var(--muted);font-size:13px;text-align:center;padding:20px;}
|
||||
.err.show{display:flex;}
|
||||
.err a{color:var(--brand);}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="hdr">
|
||||
<a href="/" style="text-decoration:none;font-size:13px;color:var(--muted)">← 홈</a>
|
||||
<h1>🖨️ 3D 프린터</h1>
|
||||
<span class="hdr-sp"></span>
|
||||
<button class="hdr-btn" onclick="reloadFrame()">↺ 새로고침</button>
|
||||
<a class="hdr-btn" href="http://192.168.0.3:5000/" target="_blank">↗ 새 탭</a>
|
||||
<button class="hdr-btn" onclick="toggleTheme()">🌙</button>
|
||||
</header>
|
||||
|
||||
<div class="frame-wrap">
|
||||
<iframe id="printer-frame" src="http://192.168.0.3:5000/" title="OctoPrint"></iframe>
|
||||
<div class="err" id="printer-err">
|
||||
<div>OctoPrint를 불러오지 못했습니다 (2층 작업실 HP 프로데스크, 192.168.0.3:5000).</div>
|
||||
<div><a href="http://192.168.0.3:5000/" target="_blank">새 탭에서 직접 열기 ↗</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
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 reloadFrame(){const f=document.getElementById('printer-frame');f.src=f.src;}
|
||||
let loaded=false;
|
||||
document.getElementById('printer-frame').addEventListener('load',()=>{loaded=true;});
|
||||
setTimeout(()=>{ if(!loaded) document.getElementById('printer-err').classList.add('show'); },8000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+695
@@ -0,0 +1,695 @@
|
||||
/**
|
||||
* VideoRTC v1.6.0 - Video player for go2rtc streaming application.
|
||||
*
|
||||
* All modern web technologies are supported in almost any browser except Apple Safari.
|
||||
*
|
||||
* Support:
|
||||
* - ECMAScript 2017 (ES8) = ES6 + async
|
||||
* - RTCPeerConnection for Safari iOS 11.0+
|
||||
* - IntersectionObserver for Safari iOS 12.2+
|
||||
* - ManagedMediaSource for Safari 17+
|
||||
*
|
||||
* Doesn't support:
|
||||
* - MediaSource for Safari iOS
|
||||
* - Customized built-in elements (extends HTMLVideoElement) because Safari
|
||||
* - Autoplay for WebRTC in Safari
|
||||
*/
|
||||
export class VideoRTC extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.DISCONNECT_TIMEOUT = 5000;
|
||||
this.RECONNECT_TIMEOUT = 15000;
|
||||
|
||||
this.CODECS = [
|
||||
'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
|
||||
'avc1.64002A', // H.264 high 4.2 (Chromecast 3rd Gen)
|
||||
'avc1.640033', // H.264 high 5.1 (Chromecast with Google TV)
|
||||
'hvc1.1.6.L153.B0', // H.265 main 5.1 (Chromecast Ultra)
|
||||
'mp4a.40.2', // AAC LC
|
||||
'mp4a.40.5', // AAC HE
|
||||
'flac', // FLAC (PCM compatible)
|
||||
'opus', // OPUS Chrome, Firefox
|
||||
];
|
||||
|
||||
/**
|
||||
* [config] Supported modes (webrtc, webrtc/tcp, mse, hls, mp4, mjpeg).
|
||||
* @type {string}
|
||||
*/
|
||||
this.mode = 'webrtc,mse,hls,mjpeg';
|
||||
|
||||
/**
|
||||
* [Config] Requested medias (video, audio, microphone).
|
||||
* @type {string}
|
||||
*/
|
||||
this.media = 'video,audio';
|
||||
|
||||
/**
|
||||
* [config] Run stream when not displayed on the screen. Default `false`.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.background = false;
|
||||
|
||||
/**
|
||||
* [config] Run stream only when player in the viewport. Stop when user scroll out player.
|
||||
* Value is percentage of visibility from `0` (not visible) to `1` (full visible).
|
||||
* Default `0` - disable;
|
||||
* @type {number}
|
||||
*/
|
||||
this.visibilityThreshold = 0;
|
||||
|
||||
/**
|
||||
* [config] Run stream only when browser page on the screen. Stop when user change browser
|
||||
* tab or minimise browser windows.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.visibilityCheck = true;
|
||||
|
||||
/**
|
||||
* [config] WebRTC configuration
|
||||
* @type {RTCConfiguration}
|
||||
*/
|
||||
this.pcConfig = {
|
||||
bundlePolicy: 'max-bundle',
|
||||
iceServers: [{urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.l.google.com:19302']}],
|
||||
sdpSemantics: 'unified-plan', // important for Chromecast 1
|
||||
};
|
||||
|
||||
/**
|
||||
* [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED
|
||||
* @type {number}
|
||||
*/
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
|
||||
/**
|
||||
* [info] WebRTC connection state.
|
||||
* @type {number}
|
||||
*/
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
|
||||
/**
|
||||
* @type {HTMLVideoElement}
|
||||
*/
|
||||
this.video = null;
|
||||
|
||||
/**
|
||||
* @type {WebSocket}
|
||||
*/
|
||||
this.ws = null;
|
||||
|
||||
/**
|
||||
* @type {string|URL}
|
||||
*/
|
||||
this.wsURL = '';
|
||||
|
||||
/**
|
||||
* @type {RTCPeerConnection}
|
||||
*/
|
||||
this.pc = null;
|
||||
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
this.connectTS = 0;
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
this.mseCodecs = '';
|
||||
|
||||
/**
|
||||
* [internal] Disconnect TimeoutID.
|
||||
* @type {number}
|
||||
*/
|
||||
this.disconnectTID = 0;
|
||||
|
||||
/**
|
||||
* [internal] Reconnect TimeoutID.
|
||||
* @type {number}
|
||||
*/
|
||||
this.reconnectTID = 0;
|
||||
|
||||
/**
|
||||
* [internal] Handler for receiving Binary from WebSocket.
|
||||
* @type {Function}
|
||||
*/
|
||||
this.ondata = null;
|
||||
|
||||
/**
|
||||
* [internal] Handlers list for receiving JSON from WebSocket.
|
||||
* @type {Object.<string,Function>}
|
||||
*/
|
||||
this.onmessage = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set video source (WebSocket URL). Support relative path.
|
||||
* @param {string|URL} value
|
||||
*/
|
||||
set src(value) {
|
||||
if (typeof value !== 'string') value = value.toString();
|
||||
if (value.startsWith('http')) {
|
||||
value = 'ws' + value.substring(4);
|
||||
} else if (value.startsWith('/')) {
|
||||
value = 'ws' + location.origin.substring(4) + value;
|
||||
}
|
||||
|
||||
this.wsURL = value;
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Play video. Support automute when autoplay blocked.
|
||||
* https://developer.chrome.com/blog/autoplay/
|
||||
*/
|
||||
play() {
|
||||
this.video.play().catch(() => {
|
||||
if (!this.video.muted) {
|
||||
this.video.muted = true;
|
||||
this.video.play().catch(er => {
|
||||
console.warn(er);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to server via WebSocket
|
||||
* @param {Object} value
|
||||
*/
|
||||
send(value) {
|
||||
if (this.ws) this.ws.send(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/** @param {Function} isSupported */
|
||||
codecs(isSupported) {
|
||||
return this.CODECS
|
||||
.filter(codec => this.media.includes(codec.includes('vc1') ? 'video' : 'audio'))
|
||||
.filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
|
||||
}
|
||||
|
||||
/**
|
||||
* `CustomElement`. Invoked each time the custom element is appended into a
|
||||
* document-connected element.
|
||||
*/
|
||||
connectedCallback() {
|
||||
if (this.disconnectTID) {
|
||||
clearTimeout(this.disconnectTID);
|
||||
this.disconnectTID = 0;
|
||||
}
|
||||
|
||||
// because video autopause on disconnected from DOM
|
||||
if (this.video) {
|
||||
const seek = this.video.seekable;
|
||||
if (seek.length > 0) {
|
||||
this.video.currentTime = seek.end(seek.length - 1);
|
||||
}
|
||||
this.play();
|
||||
} else {
|
||||
this.oninit();
|
||||
}
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* `CustomElement`. Invoked each time the custom element is disconnected from the
|
||||
* document's DOM.
|
||||
*/
|
||||
disconnectedCallback() {
|
||||
if (this.background || this.disconnectTID) return;
|
||||
if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
|
||||
|
||||
this.disconnectTID = setTimeout(() => {
|
||||
if (this.reconnectTID) {
|
||||
clearTimeout(this.reconnectTID);
|
||||
this.reconnectTID = 0;
|
||||
}
|
||||
|
||||
this.disconnectTID = 0;
|
||||
|
||||
this.ondisconnect();
|
||||
}, this.DISCONNECT_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates child DOM elements. Called automatically once on `connectedCallback`.
|
||||
*/
|
||||
oninit() {
|
||||
this.video = document.createElement('video');
|
||||
this.video.controls = true;
|
||||
this.video.playsInline = true;
|
||||
this.video.preload = 'auto';
|
||||
|
||||
this.video.style.display = 'block'; // fix bottom margin 4px
|
||||
this.video.style.width = '100%';
|
||||
this.video.style.height = '100%';
|
||||
|
||||
this.appendChild(this.video);
|
||||
|
||||
this.video.addEventListener('error', ev => {
|
||||
const err = this.video.error;
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code
|
||||
const MEDIA_ERRORS = {
|
||||
1: 'MEDIA_ERR_ABORTED',
|
||||
2: 'MEDIA_ERR_NETWORK',
|
||||
3: 'MEDIA_ERR_DECODE',
|
||||
4: 'MEDIA_ERR_SRC_NOT_SUPPORTED'
|
||||
};
|
||||
console.error('[VideoRTC] Video error:', {
|
||||
error: err ? MEDIA_ERRORS[err.code] : 'unknown',
|
||||
message: err ? err.message : 'unknown',
|
||||
codecs: this.mseCodecs || 'not set',
|
||||
readyState: this.video.readyState,
|
||||
networkState: this.video.networkState,
|
||||
currentTime: this.video.currentTime
|
||||
});
|
||||
if (this.ws) this.ws.close(); // run reconnect for broken MSE stream
|
||||
});
|
||||
|
||||
// all Safari lies about supported audio codecs
|
||||
const m = window.navigator.userAgent.match(/Version\/(\d+).+Safari/);
|
||||
if (m) {
|
||||
// AAC from v13, FLAC from v14, OPUS - unsupported
|
||||
const skip = m[1] < '13' ? 'mp4a.40.2' : m[1] < '14' ? 'flac' : 'opus';
|
||||
this.CODECS.splice(this.CODECS.indexOf(skip));
|
||||
}
|
||||
|
||||
if (this.background) return;
|
||||
|
||||
if ('hidden' in document && this.visibilityCheck) {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.disconnectedCallback();
|
||||
} else if (this.isConnected) {
|
||||
this.connectedCallback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ('IntersectionObserver' in window && this.visibilityThreshold) {
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
if (!entry.isIntersecting) {
|
||||
this.disconnectedCallback();
|
||||
} else if (this.isConnected) {
|
||||
this.connectedCallback();
|
||||
}
|
||||
});
|
||||
}, {threshold: this.visibilityThreshold});
|
||||
observer.observe(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket. Called automatically on `connectedCallback`.
|
||||
* @return {boolean} true if the connection has started.
|
||||
*/
|
||||
onconnect() {
|
||||
if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false;
|
||||
|
||||
// CLOSED or CONNECTING => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
|
||||
this.connectTS = Date.now();
|
||||
|
||||
this.ws = new WebSocket(this.wsURL);
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
this.ws.addEventListener('open', () => this.onopen());
|
||||
this.ws.addEventListener('close', () => this.onclose());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
ondisconnect() {
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
if (this.pc) {
|
||||
this.pc.getSenders().forEach(sender => {
|
||||
if (sender.track) sender.track.stop();
|
||||
});
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
|
||||
this.video.src = '';
|
||||
this.video.srcObject = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array.<string>} of modes (mse, webrtc, etc.)
|
||||
*/
|
||||
onopen() {
|
||||
// CONNECTING => OPEN
|
||||
this.wsState = WebSocket.OPEN;
|
||||
|
||||
this.ws.addEventListener('message', ev => {
|
||||
if (typeof ev.data === 'string') {
|
||||
const msg = JSON.parse(ev.data);
|
||||
for (const mode in this.onmessage) {
|
||||
this.onmessage[mode](msg);
|
||||
}
|
||||
} else {
|
||||
this.ondata(ev.data);
|
||||
}
|
||||
});
|
||||
|
||||
this.ondata = null;
|
||||
this.onmessage = {};
|
||||
|
||||
const modes = [];
|
||||
|
||||
if (this.mode.includes('mse') && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
|
||||
modes.push('mse');
|
||||
this.onmse();
|
||||
} else if (this.mode.includes('hls') && this.video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
modes.push('hls');
|
||||
this.onhls();
|
||||
} else if (this.mode.includes('mp4')) {
|
||||
modes.push('mp4');
|
||||
this.onmp4();
|
||||
}
|
||||
|
||||
if (this.mode.includes('webrtc') && 'RTCPeerConnection' in window) {
|
||||
modes.push('webrtc');
|
||||
this.onwebrtc();
|
||||
}
|
||||
|
||||
if (this.mode.includes('mjpeg')) {
|
||||
if (modes.length) {
|
||||
this.onmessage['mjpeg'] = msg => {
|
||||
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
|
||||
this.onmjpeg();
|
||||
};
|
||||
} else {
|
||||
modes.push('mjpeg');
|
||||
this.onmjpeg();
|
||||
}
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} true if reconnection has started.
|
||||
*/
|
||||
onclose() {
|
||||
if (this.wsState === WebSocket.CLOSED) return false;
|
||||
|
||||
// CONNECTING, OPEN => CONNECTING
|
||||
this.wsState = WebSocket.CONNECTING;
|
||||
this.ws = null;
|
||||
|
||||
// reconnect no more than once every X seconds
|
||||
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
|
||||
|
||||
this.reconnectTID = setTimeout(() => {
|
||||
this.reconnectTID = 0;
|
||||
this.onconnect();
|
||||
}, delay);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onmse() {
|
||||
/** @type {MediaSource} */
|
||||
let ms;
|
||||
|
||||
if ('ManagedMediaSource' in window) {
|
||||
const MediaSource = window.ManagedMediaSource;
|
||||
|
||||
ms = new MediaSource();
|
||||
ms.addEventListener('sourceopen', () => {
|
||||
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
|
||||
}, {once: true});
|
||||
|
||||
this.video.disableRemotePlayback = true;
|
||||
this.video.srcObject = ms;
|
||||
} else {
|
||||
ms = new MediaSource();
|
||||
ms.addEventListener('sourceopen', () => {
|
||||
URL.revokeObjectURL(this.video.src);
|
||||
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
|
||||
}, {once: true});
|
||||
|
||||
this.video.src = URL.createObjectURL(ms);
|
||||
this.video.srcObject = null;
|
||||
}
|
||||
|
||||
this.play();
|
||||
|
||||
this.mseCodecs = '';
|
||||
|
||||
this.onmessage['mse'] = msg => {
|
||||
if (msg.type !== 'mse') return;
|
||||
|
||||
this.mseCodecs = msg.value;
|
||||
|
||||
const sb = ms.addSourceBuffer(msg.value);
|
||||
sb.mode = 'segments'; // segments or sequence
|
||||
sb.addEventListener('updateend', () => {
|
||||
if (!sb.updating && bufLen > 0) {
|
||||
try {
|
||||
const data = buf.slice(0, bufLen);
|
||||
sb.appendBuffer(data);
|
||||
bufLen = 0;
|
||||
} catch (e) {
|
||||
// console.debug(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sb.updating && sb.buffered && sb.buffered.length) {
|
||||
const end = sb.buffered.end(sb.buffered.length - 1);
|
||||
const start = end - 5;
|
||||
const start0 = sb.buffered.start(0);
|
||||
if (start > start0) {
|
||||
sb.remove(start0, start);
|
||||
ms.setLiveSeekableRange(start, end);
|
||||
}
|
||||
if (this.video.currentTime < start) {
|
||||
this.video.currentTime = start;
|
||||
}
|
||||
const gap = end - this.video.currentTime;
|
||||
this.video.playbackRate = gap > 0.1 ? gap : 0.1;
|
||||
// console.debug('VideoRTC.buffered', gap, this.video.playbackRate, this.video.readyState);
|
||||
}
|
||||
});
|
||||
|
||||
const buf = new Uint8Array(2 * 1024 * 1024);
|
||||
let bufLen = 0;
|
||||
|
||||
this.ondata = data => {
|
||||
if (sb.updating || bufLen > 0) {
|
||||
const b = new Uint8Array(data);
|
||||
buf.set(b, bufLen);
|
||||
bufLen += b.byteLength;
|
||||
// console.debug('VideoRTC.buffer', b.byteLength, bufLen);
|
||||
} else {
|
||||
try {
|
||||
sb.appendBuffer(data);
|
||||
} catch (e) {
|
||||
// console.debug(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
onwebrtc() {
|
||||
const pc = new RTCPeerConnection(this.pcConfig);
|
||||
|
||||
pc.addEventListener('icecandidate', ev => {
|
||||
if (ev.candidate && this.mode.includes('webrtc/tcp') && ev.candidate.protocol === 'udp') return;
|
||||
|
||||
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
|
||||
this.send({type: 'webrtc/candidate', value: candidate});
|
||||
});
|
||||
|
||||
pc.addEventListener('connectionstatechange', () => {
|
||||
if (pc.connectionState === 'connected') {
|
||||
const tracks = pc.getTransceivers()
|
||||
.filter(tr => tr.currentDirection === 'recvonly') // skip inactive
|
||||
.map(tr => tr.receiver.track);
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = document.createElement('video');
|
||||
video2.addEventListener('loadeddata', () => this.onpcvideo(video2), {once: true});
|
||||
video2.srcObject = new MediaStream(tracks);
|
||||
} else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
|
||||
pc.close(); // stop next events
|
||||
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
this.pc = null;
|
||||
|
||||
this.onconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.onmessage['webrtc'] = msg => {
|
||||
switch (msg.type) {
|
||||
case 'webrtc/candidate':
|
||||
if (this.mode.includes('webrtc/tcp') && msg.value.includes(' udp ')) return;
|
||||
|
||||
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => {
|
||||
console.warn(er);
|
||||
});
|
||||
break;
|
||||
case 'webrtc/answer':
|
||||
pc.setRemoteDescription({type: 'answer', sdp: msg.value}).catch(er => {
|
||||
console.warn(er);
|
||||
});
|
||||
break;
|
||||
case 'error':
|
||||
if (!msg.value.includes('webrtc/offer')) return;
|
||||
pc.close();
|
||||
}
|
||||
};
|
||||
|
||||
this.createOffer(pc).then(offer => {
|
||||
this.send({type: 'webrtc/offer', value: offer.sdp});
|
||||
});
|
||||
|
||||
this.pcState = WebSocket.CONNECTING;
|
||||
this.pc = pc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param pc {RTCPeerConnection}
|
||||
* @return {Promise<RTCSessionDescriptionInit>}
|
||||
*/
|
||||
async createOffer(pc) {
|
||||
try {
|
||||
if (this.media.includes('microphone')) {
|
||||
const media = await navigator.mediaDevices.getUserMedia({audio: true});
|
||||
media.getTracks().forEach(track => {
|
||||
pc.addTransceiver(track, {direction: 'sendonly'});
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
|
||||
for (const kind of ['video', 'audio']) {
|
||||
if (this.media.includes(kind)) {
|
||||
pc.addTransceiver(kind, {direction: 'recvonly'});
|
||||
}
|
||||
}
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
return offer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param video2 {HTMLVideoElement}
|
||||
*/
|
||||
onpcvideo(video2) {
|
||||
if (this.pc) {
|
||||
// Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
|
||||
let rtcPriority = 0, msePriority = 0;
|
||||
|
||||
/** @type {MediaStream} */
|
||||
const stream = video2.srcObject;
|
||||
if (stream.getVideoTracks().length > 0) {
|
||||
// not the best, but a pretty simple way to check a codec
|
||||
const isH265Supported = this.pc.remoteDescription.sdp.includes('H265/90000');
|
||||
rtcPriority += isH265Supported ? 0x240 : 0x220;
|
||||
}
|
||||
if (stream.getAudioTracks().length > 0) rtcPriority += 0x102;
|
||||
|
||||
if (this.mseCodecs.includes('hvc1.')) msePriority += 0x230;
|
||||
if (this.mseCodecs.includes('avc1.')) msePriority += 0x210;
|
||||
if (this.mseCodecs.includes('mp4a.')) msePriority += 0x101;
|
||||
|
||||
if (rtcPriority >= msePriority) {
|
||||
this.video.srcObject = stream;
|
||||
this.play();
|
||||
|
||||
this.pcState = WebSocket.OPEN;
|
||||
|
||||
this.wsState = WebSocket.CLOSED;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
} else {
|
||||
this.pcState = WebSocket.CLOSED;
|
||||
if (this.pc) {
|
||||
this.pc.close();
|
||||
this.pc = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
video2.srcObject = null;
|
||||
}
|
||||
|
||||
onmjpeg() {
|
||||
this.ondata = data => {
|
||||
this.video.controls = false;
|
||||
this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data);
|
||||
};
|
||||
|
||||
this.send({type: 'mjpeg'});
|
||||
}
|
||||
|
||||
onhls() {
|
||||
this.onmessage['hls'] = msg => {
|
||||
if (msg.type !== 'hls') return;
|
||||
|
||||
const url = 'http' + this.wsURL.substring(2, this.wsURL.indexOf('/ws')) + '/hls/';
|
||||
const playlist = msg.value.replace('hls/', url);
|
||||
this.video.src = 'data:application/vnd.apple.mpegurl;base64,' + btoa(playlist);
|
||||
this.play();
|
||||
};
|
||||
|
||||
this.send({type: 'hls', value: this.codecs(type => this.video.canPlayType(type))});
|
||||
}
|
||||
|
||||
onmp4() {
|
||||
/** @type {HTMLCanvasElement} **/
|
||||
const canvas = document.createElement('canvas');
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
let context;
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const video2 = document.createElement('video');
|
||||
video2.autoplay = true;
|
||||
video2.playsInline = true;
|
||||
video2.muted = true;
|
||||
|
||||
video2.addEventListener('loadeddata', () => {
|
||||
if (!context) {
|
||||
canvas.width = video2.videoWidth;
|
||||
canvas.height = video2.videoHeight;
|
||||
context = canvas.getContext('2d');
|
||||
}
|
||||
|
||||
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
this.video.controls = false;
|
||||
this.video.poster = canvas.toDataURL('image/jpeg');
|
||||
});
|
||||
|
||||
this.ondata = data => {
|
||||
video2.src = 'data:video/mp4;base64,' + VideoRTC.btoa(data);
|
||||
};
|
||||
|
||||
this.send({type: 'mp4', value: this.codecs(this.video.canPlayType)});
|
||||
}
|
||||
|
||||
static btoa(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const len = bytes.byteLength;
|
||||
let binary = '';
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
}
|
||||
Vendored
+103
@@ -0,0 +1,103 @@
|
||||
import {VideoRTC} from './video-rtc.js';
|
||||
|
||||
/**
|
||||
* This is example, how you can extend VideoRTC player for your app.
|
||||
* Also you can check this example: https://github.com/AlexxIT/WebRTC
|
||||
*/
|
||||
class VideoStream extends VideoRTC {
|
||||
set divMode(value) {
|
||||
this.querySelector('.mode').innerText = value;
|
||||
this.querySelector('.status').innerText = '';
|
||||
}
|
||||
|
||||
set divError(value) {
|
||||
const state = this.querySelector('.mode').innerText;
|
||||
if (state !== 'loading') return;
|
||||
this.querySelector('.mode').innerText = 'error';
|
||||
this.querySelector('.status').innerText = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom GUI
|
||||
*/
|
||||
oninit() {
|
||||
console.debug('stream.oninit');
|
||||
super.oninit();
|
||||
|
||||
this.innerHTML = `
|
||||
<style>
|
||||
video-stream {
|
||||
position: relative;
|
||||
}
|
||||
.info {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12px;
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<div class="info">
|
||||
<div class="status"></div>
|
||||
<div class="mode"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const info = this.querySelector('.info');
|
||||
this.insertBefore(this.video, info);
|
||||
}
|
||||
|
||||
onconnect() {
|
||||
console.debug('stream.onconnect');
|
||||
const result = super.onconnect();
|
||||
if (result) this.divMode = 'loading';
|
||||
return result;
|
||||
}
|
||||
|
||||
ondisconnect() {
|
||||
console.debug('stream.ondisconnect');
|
||||
super.ondisconnect();
|
||||
}
|
||||
|
||||
onopen() {
|
||||
console.debug('stream.onopen');
|
||||
const result = super.onopen();
|
||||
|
||||
this.onmessage['stream'] = msg => {
|
||||
console.debug('stream.onmessge', msg);
|
||||
switch (msg.type) {
|
||||
case 'error':
|
||||
this.divError = msg.value;
|
||||
break;
|
||||
case 'mse':
|
||||
case 'hls':
|
||||
case 'mp4':
|
||||
case 'mjpeg':
|
||||
this.divMode = msg.type.toUpperCase();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
onclose() {
|
||||
console.debug('stream.onclose');
|
||||
return super.onclose();
|
||||
}
|
||||
|
||||
onpcvideo(ev) {
|
||||
console.debug('stream.onpcvideo');
|
||||
super.onpcvideo(ev);
|
||||
|
||||
if (this.pcState !== WebSocket.CLOSED) {
|
||||
this.divMode = 'RTC';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('video-stream', VideoStream);
|
||||
Reference in New Issue
Block a user