Files
HikarinHeartRateMonitorService/obs_plugin.html

558 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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