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> |