第二次提交
This commit is contained in:
BIN
assets/images/app_logo.png
Normal file
BIN
assets/images/app_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
assets/images/app_logo_black.png
Normal file
BIN
assets/images/app_logo_black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 957 KiB |
BIN
assets/images/app_logo_gray.png
Normal file
BIN
assets/images/app_logo_gray.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/images/app_logo_transparent.png
Normal file
BIN
assets/images/app_logo_transparent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 959 KiB |
BIN
assets/images/car.png
Normal file
BIN
assets/images/car.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 MiB |
340
assets/www/webrtc/playwebrtc.html
Normal file
340
assets/www/webrtc/playwebrtc.html
Normal 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>
|
||||
117
assets/www/webrtc/websocket.js
Normal file
117
assets/www/webrtc/websocket.js
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user