340 lines
9.1 KiB
HTML
340 lines
9.1 KiB
HTML
|
|
<!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>
|