Files
feature-next-arch/assets/www/webrtc/playwebrtc.html

340 lines
9.1 KiB
HTML
Raw Normal View History

2026-01-18 20:21:14 +08:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebRTC 前后视角 + PIP + 虚化背景(Apple TV 风格)</title>
<style>
body {
margin: 0;
background: #000;
overflow: hidden;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
touch-action: none;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script src="jswebrtc.min.js"></script>
<script>
/* ================= 基础 ================= */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let video = null;
let player = null;
let drawing = false;
let streamUrl = 'webrtc://1.95.137.212/live/livestream/111';
/* ================= 配置 ================= */
const PIP_WIDTH_RATIO = 0.25;
const PIP_RADIUS_RATIO = 0.04;
/* ===== 虚化背景配置 ===== */
const BLUR_BG_ENABLED = true;
const BLUR_RADIUS_PX = 28;
const BLUR_DARK_ALPHA = 0.12; // 降低暗色强度,让过渡更自然
/* ================= PIP 状态 ================= */
function createPip(x, y) {
return { x, y, dragging:false, offsetX:0, offsetY:0, visible:true };
}
const pipState = {
left: createPip(10, 10),
right: createPip(0, 10),
active: null
};
/* ================= 主画面状态 ================= */
let mainView = 'front'; // front / back
/* ================= 工具函数 ================= */
function calcContain16by9(region) {
const r = 16 / 9;
let w = region.dw, h = region.dh;
if (w / h > r) w = h * r;
else h = w / r;
return { dx:(region.dw-w)/2, dy:(region.dh-h)/2, dw:w, dh:h };
}
function getPipSize() {
const w = canvas.width * PIP_WIDTH_RATIO;
return { w, h: w * 9 / 16 };
}
function hitTest(x, y, pip) {
const s = getPipSize();
return x >= pip.x && x <= pip.x + s.w &&
y >= pip.y && y <= pip.y + s.h;
}
function clamp(pip) {
const s = getPipSize();
pip.x = Math.max(0, Math.min(canvas.width - s.w, pip.x));
pip.y = Math.max(0, Math.min(canvas.height - s.h, pip.y));
}
/* ================= Canvas ================= */
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const s = getPipSize();
pipState.right.x = canvas.width - s.w - 10;
}
window.addEventListener('resize', resizeCanvas);
/* ================= 拖动 ================= */
canvas.addEventListener('pointerdown', e => {
const r = canvas.getBoundingClientRect();
const x = e.clientX - r.left;
const y = e.clientY - r.top;
for (const k of ['left','right']) {
const pip = pipState[k];
if (pip.visible && hitTest(x, y, pip)) {
pipState.active = pip;
pip.dragging = true;
pip.offsetX = x - pip.x;
pip.offsetY = y - pip.y;
canvas.setPointerCapture(e.pointerId);
break;
}
}
});
canvas.addEventListener('pointermove', e => {
const pip = pipState.active;
if (!pip || !pip.dragging) return;
const r = canvas.getBoundingClientRect();
pip.x = e.clientX - r.left - pip.offsetX;
pip.y = e.clientY - r.top - pip.offsetY;
clamp(pip);
});
canvas.addEventListener('pointerup', resetDrag);
canvas.addEventListener('pointercancel', resetDrag);
function resetDrag() {
if (pipState.active) pipState.active.dragging = false;
pipState.active = null;
}
/* ================= WebRTC ================= */
function createVideoElement() {
const v = document.createElement('video');
v.style.display = 'none';
v.playsInline = true;
v.muted = true;
document.body.appendChild(v);
return v;
}
function destroyPlayer() {
drawing = false;
if (player) {
try { player.destroy(); } catch(e) {}
player = null;
}
if (video) {
video.pause();
video.srcObject = null;
video.remove();
video = null;
}
}
function initPlayer() {
destroyPlayer();
video = createVideoElement();
console.log('[WebRTC] init:', streamUrl);
player = new JSWebrtc.Player(streamUrl, {
video,
autoplay: true,
onPlay: () => {
resizeCanvas();
drawing = true;
requestAnimationFrame(draw);
},
onError: err => {
console.error('[WebRTC] error', err);
}
});
}
/* ================= 圆角 ================= */
function roundRectPath(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x+r, y);
ctx.arcTo(x+w, y, x+w, y+h, r);
ctx.arcTo(x+w, y+h, x, y+h, r);
ctx.arcTo(x, y+h, x, y, r);
ctx.arcTo(x, y, x+w, y, r);
ctx.closePath();
}
function drawBlurBackground(video) {
if (!BLUR_BG_ENABLED) return;
const vw = video.videoWidth;
const vh = video.videoHeight;
let sx = 0, sy = 0, sw = vw / 2, sh = vh / 2;
if (mainView === 'back') sx = vw / 2;
const main = calcContain16by9({ dw: canvas.width, dh: canvas.height });
/* ========= 1. 整屏强模糊背景 ========= */
ctx.save();
ctx.filter = `blur(${BLUR_RADIUS_PX}px)`;
ctx.drawImage(
video,
sx, sy, sw, sh,
0, 0, canvas.width, canvas.height
);
ctx.restore();
ctx.fillStyle = `rgba(0,0,0,${BLUR_DARK_ALPHA})`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
/* ========= 2. 盖回清晰画面(关键) ========= */
ctx.save();
const cx = main.dx + main.dw / 2;
const cy = main.dy + main.dh / 2;
const base = Math.min(main.dw, main.dh);
// ✅ 完全清晰的“安全区”
const clearRadius = base * 0.42;
// ✅ 开始渐变的边界
const fadeRadius = base * 0.62;
const mask = ctx.createRadialGradient(
cx, cy, clearRadius,
cx, cy, fadeRadius
);
/*
0.0 ~ 1.0 的意义:
0 = 不透明(画清晰)
1 = 完全透明(露出模糊)
*/
// ★ 重点:前段完全没有任何渐变
mask.addColorStop(0.0, 'rgba(0,0,0,1)');
mask.addColorStop(0.65, 'rgba(0,0,0,1)');
// ★ 从这里才“开始虚”
mask.addColorStop(0.82, 'rgba(0,0,0,0.4)');
mask.addColorStop(1.0, 'rgba(0,0,0,0)');
ctx.globalCompositeOperation = 'destination-over';
ctx.fillStyle = mask;
ctx.fillRect(
main.dx - base,
main.dy - base,
main.dw + base * 2,
main.dh + base * 2
);
ctx.restore();
}
/* ================= PIP ================= */
function drawPip(pip, sx, sy, sw, sh) {
if (!pip.visible) return;
const s = getPipSize();
const r = s.w * PIP_RADIUS_RATIO;
ctx.save();
roundRectPath(pip.x, pip.y, s.w, s.h, r);
ctx.clip();
ctx.drawImage(video, sx, sy, sw, sh, pip.x, pip.y, s.w, s.h);
ctx.restore();
}
/* ================= 绘制 ================= */
function draw() {
if (!drawing || !video || video.readyState < video.HAVE_ENOUGH_DATA) {
requestAnimationFrame(draw);
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
const vw = video.videoWidth;
const vh = video.videoHeight;
/* ① 虚化背景:仅基于当前主视角内容 */
drawBlurBackground(video);
/* ② 主画面(清晰) */
const main = calcContain16by9({ dw: canvas.width, dh: canvas.height });
let sx = 0, sy = 0, sw = vw / 2, sh = vh / 2;
if (mainView === 'back') sx = vw / 2;
ctx.drawImage(
video,
sx, sy, sw, sh,
main.dx, main.dy, main.dw, main.dh
);
/* ③ PIP(小窗) */
drawPip(pipState.left, 0, vh/2, vw/2, vh/2);
drawPip(pipState.right, vw/2, vh/2, vw/2, vh/2);
requestAnimationFrame(draw);
}
/* ================= JS 接口 ================= */
window.setPipVisible = function({location, flag}) {
if (location === 'left') pipState.left.visible = !!flag;
if (location === 'right') pipState.right.visible = !!flag;
};
window.setMainView = function({view}) {
if (view === 'front' || view === 'back') mainView = view;
};
window.toggleMainView = function() {
mainView = (mainView === 'front') ? 'back' : 'front';
};
window.setStreamUrl = function({url}) {
if (!url) return;
streamUrl = url;
setTimeout(initPlayer, 80);
};
window.refreshSelf = function() {
location.reload();
};
/* ================= 启动 ================= */
resizeCanvas();
initPlayer();
</script>
</body>
</html>