第二次提交

This commit is contained in:
cc
2026-01-18 20:21:14 +08:00
parent 4346601481
commit 289e706337
129 changed files with 4615 additions and 135 deletions

BIN
assets/images/app_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 KiB

BIN
assets/images/car.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 MiB

View File

@@ -0,0 +1,340 @@
<!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>

View File

@@ -0,0 +1,117 @@
class WebSocketClient {
constructor(url) {
this.url = url;
this.socket = null;
this.connected = false;
this.reconnecting = false;
this.reconnectInterval = 3000; // 重连间隔(ms)
this.maxReconnectAttempts = 10; // 最大重连次数
this.reconnectAttempts = 0;
this.messageQueue = []; // 消息队列
// 事件回调
this.onConnect = null;
this.onMessage = null;
this.onClose = null;
this.onError = null;
}
// 连接WebSocket服务器
connect() {
if (this.socket && (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN)) {
return;
}
this.socket = new WebSocket(this.url);
this.socket.onopen = (event) => {
this.connected = true;
this.reconnecting = false;
this.reconnectAttempts = 0;
console.log('WebSocket连接已建立');
// 发送队列中的所有消息
this._sendQueuedMessages();
if (typeof this.onConnect === 'function') {
this.onConnect(event);
}
};
this.socket.onmessage = (event) => {
console.log('收到消息:', event.data);
this.onMessage(event.data);
};
this.socket.onclose = (event) => {
this.connected = false;
console.log('WebSocket连接已关闭,代码:', event.code, '原因:', event.reason);
if (typeof this.onClose === 'function') {
this.onClose(event);
}
// 非主动关闭时尝试重连
if (!this.reconnecting && event.code !== 1000) {
this._scheduleReconnect();
}
};
this.socket.onerror = (error) => {
console.error('WebSocket错误:', error);
if (typeof this.onError === 'function') {
this.onError(error);
}
};
}
// 发送消息
send(message) {
if (this.connected && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(message);
} else {
// 连接未建立,将消息加入队列
this.messageQueue.push(message);
if (!this.reconnecting) {
this._scheduleReconnect();
}
}
}
// 关闭连接
close(code = 1000, reason = '') {
this.reconnecting = false;
if (this.socket) {
this.socket.close(code, reason);
}
}
// 安排重连
_scheduleReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnecting = true;
this.reconnectAttempts++;
const delay = this.reconnectInterval * Math.min(1, this.reconnectAttempts / 3); // 指数退避
console.log(`尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts}),${delay/1000}秒后...`);
setTimeout(() => {
console.log('正在重连...');
this.connect();
}, delay);
} else {
console.error('达到最大重连次数,停止重连');
this.reconnecting = false;
}
}
// 发送队列中的消息
_sendQueuedMessages() {
if (this.messageQueue.length > 0) {
console.log(`发送队列中的${this.messageQueue.length}条消息`);
this.messageQueue.forEach(message => {
this.socket.send(message);
});
this.messageQueue = [];
}
}
}