first commit and delete token
This commit is contained in:
558
obs_plugin.html
Normal file
558
obs_plugin.html
Normal file
@ -0,0 +1,558 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>心率监控 - OBS插件</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.heart-rate-container {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(0, 0, 0, 0.4),
|
||||
rgba(50, 50, 50, 0.4),
|
||||
rgba(255, 0, 0, 0.1),
|
||||
rgba(255, 100, 100, 0.1));
|
||||
border: 2px solid rgba(255, 0, 0, 0.8);
|
||||
border-radius: 15px;
|
||||
padding: 15px;
|
||||
width: 400px;
|
||||
height: 280px;
|
||||
position: relative;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.heart-icon {
|
||||
font-size: 24px;
|
||||
color: #ff4757;
|
||||
animation: heartbeat 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes heartbeat {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.current-bpm {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #ff4757;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.bpm-label {
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.device-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ff4757;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-weight: bold;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.last-update {
|
||||
font-size: 9px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 120px;
|
||||
margin-top: 8px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.9);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.disconnected {
|
||||
filter: grayscale(100%);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.disconnected .status-dot {
|
||||
background: #666;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.zone-indicator {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.zone-rest { background: #74b9ff; color: white; }
|
||||
.zone-fat-burn { background: #00b894; color: white; }
|
||||
.zone-cardio { background: #fdcb6e; color: black; }
|
||||
.zone-peak { background: #e17055; color: white; }
|
||||
.zone-max { background: #d63031; color: white; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="heart-rate-container" id="heartRateContainer">
|
||||
<div class="header">
|
||||
<div class="heart-icon">❤️</div>
|
||||
<div class="current-bpm" id="currentBPM">--</div>
|
||||
<div class="bpm-label">BPM</div>
|
||||
</div>
|
||||
|
||||
<div class="device-info">
|
||||
<div class="status-dot" id="statusDot"></div>
|
||||
<span class="device-name" id="deviceName">等待连接...</span>
|
||||
<span class="last-update" id="lastUpdate"></span>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<canvas class="chart-canvas" id="chartCanvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="avgBPM">--</div>
|
||||
<div class="stat-label">平均</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="maxBPM">--</div>
|
||||
<div class="stat-label">最高</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="minBPM">--</div>
|
||||
<div class="stat-label">最低</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="duration">00:00</div>
|
||||
<div class="stat-label">时长</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class HeartRateMonitor {
|
||||
constructor() {
|
||||
this.heartRateData = [];
|
||||
this.maxDataPoints = 100;
|
||||
this.isConnected = false;
|
||||
this.startTime = null;
|
||||
this.lastUpdateTime = 0;
|
||||
this.currentDeviceName = '';
|
||||
|
||||
this.canvas = document.getElementById('chartCanvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
|
||||
this.currentBPMElement = document.getElementById('currentBPM');
|
||||
this.deviceNameElement = document.getElementById('deviceName');
|
||||
this.lastUpdateElement = document.getElementById('lastUpdate');
|
||||
this.statusDotElement = document.getElementById('statusDot');
|
||||
this.avgBPMElement = document.getElementById('avgBPM');
|
||||
this.maxBPMElement = document.getElementById('maxBPM');
|
||||
this.minBPMElement = document.getElementById('minBPM');
|
||||
this.durationElement = document.getElementById('duration');
|
||||
this.containerElement = document.getElementById('heartRateContainer');
|
||||
|
||||
this.setupCanvas();
|
||||
this.setupWebSocket();
|
||||
this.startUpdateLoop();
|
||||
}
|
||||
|
||||
setupCanvas() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
this.canvas.width = rect.width * devicePixelRatio;
|
||||
this.canvas.height = rect.height * devicePixelRatio;
|
||||
this.ctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
this.canvas.style.width = rect.width + 'px';
|
||||
this.canvas.style.height = rect.height + 'px';
|
||||
}
|
||||
|
||||
setupWebSocket() {
|
||||
// WebSocket连接,用于接收心率数据
|
||||
try {
|
||||
this.ws = new WebSocket('wss://ws.nuanr-mxi.com/ws');
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.setConnected(true);
|
||||
console.log('WebSocket连接已建立');
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const heartRateResponse = JSON.parse(event.data);
|
||||
this.processHeartRateResponse(heartRateResponse);
|
||||
} catch (error) {
|
||||
console.error('解析心率数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.setConnected(false);
|
||||
console.log('WebSocket连接已关闭');
|
||||
// 5秒后重连
|
||||
setTimeout(() => this.setupWebSocket(), 5000);
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket错误:', error);
|
||||
this.setConnected(false);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('WebSocket连接失败:', error);
|
||||
this.setConnected(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
processHeartRateResponse(heartRateResponse) {
|
||||
// 处理C#发送的HeartRateResponse数据结构
|
||||
if (!heartRateResponse || !heartRateResponse.HeartRate) {
|
||||
console.warn('无效的心率数据:', heartRateResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const bpm = heartRateResponse.HeartRate;
|
||||
let timestamp;
|
||||
|
||||
// 处理C#的DateTime格式
|
||||
if (heartRateResponse.Timestamp) {
|
||||
if (typeof heartRateResponse.Timestamp === 'string') {
|
||||
// 如果是ISO字符串格式
|
||||
timestamp = new Date(heartRateResponse.Timestamp).getTime();
|
||||
} else {
|
||||
// 如果是其他格式,尝试解析
|
||||
timestamp = new Date(heartRateResponse.Timestamp).getTime();
|
||||
}
|
||||
} else {
|
||||
timestamp = Date.now();
|
||||
}
|
||||
|
||||
// 更新设备名称
|
||||
if (heartRateResponse.DeviceName && heartRateResponse.DeviceName !== this.currentDeviceName) {
|
||||
this.currentDeviceName = heartRateResponse.DeviceName;
|
||||
this.deviceNameElement.textContent = this.currentDeviceName;
|
||||
}
|
||||
|
||||
// 添加心率数据
|
||||
this.addHeartRateData(bpm, timestamp);
|
||||
|
||||
// 更新最后更新时间显示
|
||||
this.updateLastUpdateTime(timestamp);
|
||||
}
|
||||
|
||||
addHeartRateData(bpm, timestamp = Date.now()) {
|
||||
if (bpm <= 0 || bpm > 250) return; // 过滤无效数据
|
||||
|
||||
const dataPoint = {
|
||||
bpm: bpm,
|
||||
timestamp: timestamp,
|
||||
time: new Date(timestamp)
|
||||
};
|
||||
|
||||
this.heartRateData.push(dataPoint);
|
||||
|
||||
// 保持最多100个数据点
|
||||
if (this.heartRateData.length > this.maxDataPoints) {
|
||||
this.heartRateData.shift();
|
||||
}
|
||||
|
||||
// 设置开始时间
|
||||
if (!this.startTime) {
|
||||
this.startTime = timestamp;
|
||||
}
|
||||
|
||||
this.lastUpdateTime = timestamp;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
updateLastUpdateTime(timestamp) {
|
||||
const updateTime = new Date(timestamp);
|
||||
const timeString = updateTime.toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
this.lastUpdateElement.textContent = timeString;
|
||||
}
|
||||
|
||||
setConnected(connected) {
|
||||
this.isConnected = connected;
|
||||
if (connected) {
|
||||
this.containerElement.classList.remove('disconnected');
|
||||
if (!this.currentDeviceName) {
|
||||
this.deviceNameElement.textContent = '已连接';
|
||||
}
|
||||
} else {
|
||||
this.containerElement.classList.add('disconnected');
|
||||
this.deviceNameElement.textContent = '连接断开';
|
||||
this.lastUpdateElement.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
updateDisplay() {
|
||||
if (this.heartRateData.length === 0) return;
|
||||
|
||||
const currentBPM = this.heartRateData[this.heartRateData.length - 1].bpm;
|
||||
const avgBPM = this.calculateAverage();
|
||||
const maxBPM = this.calculateMax();
|
||||
const minBPM = this.calculateMin();
|
||||
const duration = this.calculateDuration();
|
||||
|
||||
this.currentBPMElement.textContent = currentBPM;
|
||||
this.avgBPMElement.textContent = avgBPM;
|
||||
this.maxBPMElement.textContent = maxBPM;
|
||||
this.minBPMElement.textContent = minBPM;
|
||||
this.durationElement.textContent = duration;
|
||||
|
||||
this.updateZoneIndicator(currentBPM);
|
||||
this.drawChart();
|
||||
}
|
||||
|
||||
calculateAverage() {
|
||||
if (this.heartRateData.length === 0) return '--';
|
||||
const sum = this.heartRateData.reduce((acc, data) => acc + data.bpm, 0);
|
||||
return Math.round(sum / this.heartRateData.length);
|
||||
}
|
||||
|
||||
calculateMax() {
|
||||
if (this.heartRateData.length === 0) return '--';
|
||||
return Math.max(...this.heartRateData.map(data => data.bpm));
|
||||
}
|
||||
|
||||
calculateMin() {
|
||||
if (this.heartRateData.length === 0) return '--';
|
||||
return Math.min(...this.heartRateData.map(data => data.bpm));
|
||||
}
|
||||
|
||||
calculateDuration() {
|
||||
if (!this.startTime) return '00:00';
|
||||
const duration = Math.floor((this.lastUpdateTime - this.startTime) / 1000);
|
||||
const minutes = Math.floor(duration / 60);
|
||||
const seconds = duration % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
|
||||
drawChart() {
|
||||
const canvas = this.canvas;
|
||||
const ctx = this.ctx;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
if (this.heartRateData.length < 2) return;
|
||||
|
||||
// 计算绘图区域
|
||||
const padding = 20;
|
||||
const chartWidth = width - padding * 2;
|
||||
const chartHeight = height - padding * 2;
|
||||
|
||||
// 计算数据范围
|
||||
const minBPM = Math.min(...this.heartRateData.map(d => d.bpm));
|
||||
const maxBPM = Math.max(...this.heartRateData.map(d => d.bpm));
|
||||
const range = Math.max(maxBPM - minBPM, 20); // 最小范围20
|
||||
const adjustedMin = Math.max(minBPM - 10, 0);
|
||||
const adjustedMax = adjustedMin + range + 20;
|
||||
|
||||
// 绘制网格线
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// 横向网格线
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = padding + (chartHeight / 4) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding, y);
|
||||
ctx.lineTo(width - padding, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 纵向网格线
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const x = padding + (chartWidth / 5) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padding);
|
||||
ctx.lineTo(x, height - padding);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制心率曲线
|
||||
ctx.strokeStyle = '#ff4757';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
|
||||
this.heartRateData.forEach((data, index) => {
|
||||
const x = padding + (index / (this.maxDataPoints - 1)) * chartWidth;
|
||||
const y = height - padding - ((data.bpm - adjustedMin) / (adjustedMax - adjustedMin)) * chartHeight;
|
||||
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制填充区域
|
||||
ctx.fillStyle = 'rgba(255, 71, 87, 0.1)';
|
||||
ctx.lineTo(width - padding, height - padding);
|
||||
ctx.lineTo(padding, height - padding);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// 绘制当前值点
|
||||
if (this.heartRateData.length > 0) {
|
||||
const lastData = this.heartRateData[this.heartRateData.length - 1];
|
||||
const lastIndex = this.heartRateData.length - 1;
|
||||
const x = padding + (lastIndex / (this.maxDataPoints - 1)) * chartWidth;
|
||||
const y = height - padding - ((lastData.bpm - adjustedMin) / (adjustedMax - adjustedMin)) * chartHeight;
|
||||
|
||||
ctx.fillStyle = '#ff4757';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 4, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
|
||||
// 绘制外圈动画
|
||||
ctx.strokeStyle = 'rgba(255, 71, 87, 0.5)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 6 + Math.sin(Date.now() / 200) * 2, 0, 2 * Math.PI);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制Y轴标签
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
||||
ctx.font = '10px Arial';
|
||||
ctx.textAlign = 'right';
|
||||
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const value = Math.round(adjustedMax - (adjustedMax - adjustedMin) * (i / 4));
|
||||
const y = padding + (chartHeight / 4) * i + 3;
|
||||
ctx.fillText(value.toString(), padding - 5, y);
|
||||
}
|
||||
}
|
||||
|
||||
startUpdateLoop() {
|
||||
const update = () => {
|
||||
this.drawChart();
|
||||
requestAnimationFrame(update);
|
||||
};
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口大小调整时重新设置画布
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.heartRateMonitor) {
|
||||
window.heartRateMonitor.setupCanvas();
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化监控器
|
||||
window.heartRateMonitor = new HeartRateMonitor();
|
||||
|
||||
// 暴露全局接口,用于外部调用 - 兼容HeartRateResponse数据结构
|
||||
window.addHeartRateResponse = (heartRateResponse) => {
|
||||
window.heartRateMonitor.processHeartRateResponse(heartRateResponse);
|
||||
};
|
||||
|
||||
// 暴露传统接口,保持向后兼容
|
||||
window.addHeartRateData = (bpm, timestamp) => {
|
||||
const heartRateResponse = {
|
||||
HeartRate: bpm,
|
||||
Timestamp: timestamp ? new Date(timestamp).toISOString() : new Date().toISOString(),
|
||||
DeviceName: "外部设备"
|
||||
};
|
||||
window.heartRateMonitor.processHeartRateResponse(heartRateResponse);
|
||||
};
|
||||
|
||||
// 暴露连接状态设置接口
|
||||
window.setHeartRateConnected = (connected) => {
|
||||
window.heartRateMonitor.setConnected(connected);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user