Hezu06 commited on
Commit
792dfe4
·
1 Parent(s): 0b72b24

refactor index.html for improved layout, styling, and user interaction

Browse files
Files changed (1) hide show
  1. index.html +369 -135
index.html CHANGED
@@ -14,8 +14,10 @@
14
  flex-direction: column;
15
  align-items: center;
16
  justify-content: center;
17
- height: 100vh;
18
  margin: 0;
 
 
19
  }
20
  .container {
21
  background-color: #16213e;
@@ -23,260 +25,492 @@
23
  border-radius: 15px;
24
  text-align: center;
25
  box-shadow: 0 10px 30px rgba(0,0,0,0.5);
 
 
26
  }
27
  h1 { color: #fff; margin-bottom: 5px; }
28
- p { color: #a2a2bd; margin-bottom: 30px; }
29
-
30
  #bpm-display {
31
  font-size: 80px;
32
  font-weight: bold;
33
- margin: 20px 0;
34
  text-shadow: 0 0 15px #e94560;
35
  transition: color 0.3s ease;
36
  }
37
 
38
- @keyframes pulse {
39
- 0% { transform: scale(1); opacity: 1; }
40
- 50% { transform: scale(1.05); opacity: 0.8; }
41
- 100% { transform: scale(1); opacity: 1; }
 
 
 
 
 
 
 
 
 
 
 
42
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  .button-group {
45
  display: flex;
46
  justify-content: center;
47
  flex-wrap: wrap;
48
- gap: 15px;
49
- max-width: 600px;
50
  }
51
-
52
  button {
53
  background-color: #0f3460;
54
  color: white;
55
  border: none;
56
- padding: 15px 25px;
57
- font-size: 16px;
58
  border-radius: 8px;
59
  cursor: pointer;
60
  transition: 0.3s;
61
  font-weight: bold;
62
  }
63
  button:hover { background-color: #4ecca3; color: #1a1a2e; }
64
- button:disabled { background-color: #333; cursor: not-allowed; color: #777; }
65
-
66
- /* CSS riêng cho nút Dừng nhạc */
67
  #btn-stop { background-color: #8b0000; }
68
  #btn-stop:hover { background-color: #ff4d4d; color: white; }
69
- #btn-stop:disabled { background-color: #333; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
- #status { margin-top: 20px; color: #4ecca3; font-style: italic; font-size: 14px;}
 
 
 
 
 
 
 
 
 
 
 
72
  </style>
73
  </head>
74
  <body>
 
 
 
 
 
75
 
76
- <div class="container">
77
- <h1>Bio-Vibe 🎧</h1>
78
- <p>Âm nhạc trị liệu AI theo Nhịp tim</p>
 
 
79
 
80
- <div id="bpm-display">-- BPM</div>
81
-
 
 
 
 
 
82
  <div class="button-group">
83
- <button id="btn-connect">1. Bắt tay Đồng hồ</button>
84
- <button id="btn-generate">2. Sinh nhạc AI</button>
85
- <button id="btn-stop" disabled>3. Dừng nhạc</button>
 
 
 
 
 
 
 
 
 
86
  </div>
 
87
 
88
- <div id="status">Chưa kết nối...</div>
 
 
 
89
  </div>
90
 
 
 
 
 
 
 
 
 
 
91
  <script>
 
 
 
92
  let currentBpm = 0;
93
- let detectedCharUUID = null;
94
  let bpmHistory = [];
95
  let currentAudio = null;
 
96
 
97
- // DOM
98
- const bpmDisplay = document.getElementById('bpm-display');
99
- const statusText = document.getElementById('status');
100
- const btnConnect = document.getElementById('btn-connect');
101
  const btnGenerate = document.getElementById('btn-generate');
102
- const btnStop = document.getElementById('btn-stop');
103
-
104
- // ================================
105
- // SMOOTH BPM
106
- // ================================
107
- function smoothBpm(newVal) {
108
- bpmHistory.push(newVal);
109
- if (bpmHistory.length > 5) bpmHistory.shift();
110
-
111
- return Math.round(
112
- bpmHistory.reduce((a, b) => a + b, 0) / bpmHistory.length
113
- );
114
  }
115
 
116
- // ================================
117
- // AUTO DETECT DATA
118
- // ================================
119
- // ================================
120
- // ĐỌC DỮ LIỆU NHỊP TIM CHUẨN BLE
121
- // ================================
122
- function handleAutoData(event, uuid) {
123
- const value = event.target.value;
124
-
125
- let raw = [];
126
- for (let i = 0; i < value.byteLength; i++) {
127
- raw.push(value.getUint8(i));
128
- }
129
 
130
- console.log("📡 UUID:", uuid, "DATA:", raw);
 
 
 
131
 
132
- for (let i = 0; i < raw.length; i++) {
133
- let v = raw[i];
 
 
 
 
 
134
 
135
- if (v >= 40 && v <= 180) {
 
 
 
 
 
 
 
136
 
137
- let newBpm = smoothBpm(v);
 
 
138
 
139
- if (currentBpm !== 0 && Math.abs(newBpm - currentBpm) > 40) {
140
- continue;
141
- }
 
 
 
 
142
 
143
- currentBpm = newBpm;
 
 
 
 
 
 
 
 
 
 
 
144
 
145
- bpmDisplay.innerText = `${currentBpm} BPM`;
146
- bpmDisplay.style.color = "#e94560";
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
- statusText.innerText = `Đã detect BPM (${uuid})`;
149
- statusText.style.color = "#4ecca3";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
- detectedCharUUID = uuid;
 
 
 
 
 
 
152
  break;
153
  }
154
  }
155
  }
156
 
157
- // ================================
158
- // AUTO SCAN BLE
159
- // ================================
160
  btnConnect.addEventListener('click', async () => {
 
 
 
 
 
161
  try {
162
- statusText.innerText = "Đang scan BLE...";
 
163
 
164
  const device = await navigator.bluetooth.requestDevice({
165
  acceptAllDevices: true,
166
  optionalServices: [
 
 
167
  'battery_service',
168
  'device_information',
169
- 'heart_rate'
 
 
 
 
 
 
 
 
 
170
  ]
171
  });
172
 
173
- const server = await device.gatt.connect();
 
 
 
174
  const services = await server.getPrimaryServices();
 
175
 
176
- for (const service of services) {
177
- console.log("🔵 Service:", service.uuid);
178
 
179
- const characteristics = await service.getCharacteristics();
 
 
 
 
 
 
 
 
180
 
181
- for (const char of characteristics) {
182
- console.log("⚪ Char:", char.uuid, char.properties);
 
183
 
184
- if (char.properties.notify || char.properties.indicate) {
185
  try {
186
  await char.startNotifications();
187
-
188
- char.addEventListener(
189
- 'characteristicvaluechanged',
190
- (event) => handleAutoData(event, char.uuid)
191
- );
192
-
193
- console.log("✅ Subscribed:", char.uuid);
194
- } catch (err) {
195
- console.log("❌ Cannot subscribe:", char.uuid);
196
  }
197
  }
198
  }
199
  }
200
 
201
- statusText.innerText = "Đã kết nối! Đang chờ BPM...";
202
- btnConnect.innerText = "✓ BLE Connected";
203
 
204
- } catch (error) {
205
- console.error(error);
206
- statusText.innerText = "Lỗi BLE!";
207
- statusText.style.color = "#ff4d4d";
 
 
 
 
 
208
  }
209
  });
210
 
211
- // ================================
212
- // GENERATE MUSIC (GIỮ NGUYÊN LOGIC)
213
- // ================================
214
  btnGenerate.addEventListener('click', async () => {
215
  if (currentBpm === 0) {
216
- alert("Chưa có BPM!");
217
  return;
218
  }
219
 
220
- // stop audio cũ
221
  if (currentAudio) {
222
  currentAudio.pause();
223
  currentAudio.currentTime = 0;
224
- bpmDisplay.style.animation = "none";
225
  }
226
 
227
- statusText.innerText = `Đang gửi ${currentBpm} BPM cho AI...`;
228
- btnGenerate.innerText = "AI đang sáng tác...";
229
- btnGenerate.disabled = true;
230
- btnStop.disabled = true;
231
 
232
  try {
233
- const response = await fetch('/generate-vibe', {
234
- method: 'POST',
235
  headers: { 'Content-Type': 'application/json' },
236
- body: JSON.stringify({ bpm: currentBpm })
237
  });
 
238
 
239
- const data = await response.json();
240
-
241
- if (data.status === "success") {
242
  currentAudio = new Audio(data.audio_url);
243
  currentAudio.play();
244
-
245
- bpmDisplay.style.animation = "pulse 1s infinite";
246
-
247
- statusText.innerText = "🎵 Đang phát nhạc...";
248
  btnStop.disabled = false;
249
 
250
  currentAudio.onended = () => {
251
- bpmDisplay.style.animation = "none";
252
  btnStop.disabled = true;
253
- statusText.innerText = "Nhạc đã kết thúc.";
254
  };
255
-
256
  } else {
257
- statusText.innerText = "Lỗi AI!";
258
  }
259
-
260
- } catch (error) {
261
- console.error(error);
262
- statusText.innerText = "Mất kết nối server!";
263
  } finally {
264
- btnGenerate.innerText = "2. Sinh nhạc AI";
265
- btnGenerate.disabled = false;
266
  }
267
  });
268
 
269
- // ================================
270
  // STOP MUSIC
271
- // ================================
272
  btnStop.addEventListener('click', () => {
273
  if (currentAudio) {
274
  currentAudio.pause();
275
- currentAudio.currentTime = 0;
276
-
277
- bpmDisplay.style.animation = "none";
278
- statusText.innerText = "Đã dừng nhạc.";
279
- btnStop.disabled = true;
280
  }
281
  });
282
  </script>
 
14
  flex-direction: column;
15
  align-items: center;
16
  justify-content: center;
17
+ min-height: 100vh;
18
  margin: 0;
19
+ padding: 20px;
20
+ box-sizing: border-box;
21
  }
22
  .container {
23
  background-color: #16213e;
 
25
  border-radius: 15px;
26
  text-align: center;
27
  box-shadow: 0 10px 30px rgba(0,0,0,0.5);
28
+ max-width: 620px;
29
+ width: 100%;
30
  }
31
  h1 { color: #fff; margin-bottom: 5px; }
32
+ .subtitle { color: #a2a2bd; margin-bottom: 20px; }
33
+
34
  #bpm-display {
35
  font-size: 80px;
36
  font-weight: bold;
37
+ margin: 15px 0;
38
  text-shadow: 0 0 15px #e94560;
39
  transition: color 0.3s ease;
40
  }
41
 
42
+ @keyframes pulse {
43
+ 0% { transform: scale(1); opacity: 1; }
44
+ 50% { transform: scale(1.05); opacity: 0.8; }
45
+ 100% { transform: scale(1); opacity: 1; }
46
+ }
47
+
48
+ /* ---- TABS ---- */
49
+ .tab-group {
50
+ display: flex;
51
+ justify-content: center;
52
+ gap: 0;
53
+ margin-bottom: 20px;
54
+ border-radius: 8px;
55
+ overflow: hidden;
56
+ border: 1px solid #0f3460;
57
  }
58
+ .tab-btn {
59
+ flex: 1;
60
+ background: #0f3460;
61
+ color: #a2a2bd;
62
+ border: none;
63
+ padding: 10px 16px;
64
+ font-size: 14px;
65
+ cursor: pointer;
66
+ transition: 0.2s;
67
+ font-weight: bold;
68
+ }
69
+ .tab-btn.active { background: #4ecca3; color: #1a1a2e; }
70
+ .tab-btn:hover:not(.active) { background: #1a3a6e; color: #fff; }
71
+
72
+ .tab-panel { display: none; }
73
+ .tab-panel.active { display: block; }
74
 
75
+ /* ---- MANUAL INPUT ---- */
76
+ .manual-group {
77
+ display: flex;
78
+ align-items: center;
79
+ justify-content: center;
80
+ gap: 12px;
81
+ margin: 10px 0 16px;
82
+ }
83
+ #manual-bpm-input {
84
+ background: #0f3460;
85
+ border: 1px solid #4ecca3;
86
+ border-radius: 8px;
87
+ color: #fff;
88
+ font-size: 28px;
89
+ font-weight: bold;
90
+ width: 110px;
91
+ text-align: center;
92
+ padding: 8px;
93
+ }
94
+ #manual-bpm-input:focus { outline: 2px solid #4ecca3; }
95
+ #btn-manual-set {
96
+ background: #4ecca3;
97
+ color: #1a1a2e;
98
+ border: none;
99
+ padding: 10px 18px;
100
+ font-size: 15px;
101
+ border-radius: 8px;
102
+ cursor: pointer;
103
+ font-weight: bold;
104
+ transition: 0.2s;
105
+ }
106
+ #btn-manual-set:hover { background: #38b499; }
107
+
108
+ /* ---- BUTTONS ---- */
109
  .button-group {
110
  display: flex;
111
  justify-content: center;
112
  flex-wrap: wrap;
113
+ gap: 12px;
114
+ margin-top: 10px;
115
  }
 
116
  button {
117
  background-color: #0f3460;
118
  color: white;
119
  border: none;
120
+ padding: 13px 22px;
121
+ font-size: 15px;
122
  border-radius: 8px;
123
  cursor: pointer;
124
  transition: 0.3s;
125
  font-weight: bold;
126
  }
127
  button:hover { background-color: #4ecca3; color: #1a1a2e; }
128
+ button:disabled { background-color: #2a2a3e; cursor: not-allowed; color: #555; }
129
+
 
130
  #btn-stop { background-color: #8b0000; }
131
  #btn-stop:hover { background-color: #ff4d4d; color: white; }
132
+ #btn-stop:disabled { background-color: #2a2a3e; }
133
+
134
+ /* ---- STATUS ---- */
135
+ #status {
136
+ margin-top: 18px;
137
+ font-size: 13px;
138
+ font-style: italic;
139
+ min-height: 20px;
140
+ }
141
+ .status-ok { color: #4ecca3; }
142
+ .status-err { color: #ff4d4d; }
143
+ .status-info { color: #a2a2bd; }
144
+
145
+ /* ---- DEBUG LOG ---- */
146
+ .debug-section {
147
+ margin-top: 20px;
148
+ text-align: left;
149
+ }
150
+ .debug-toggle {
151
+ background: none;
152
+ border: none;
153
+ color: #555;
154
+ font-size: 12px;
155
+ cursor: pointer;
156
+ padding: 4px 0;
157
+ font-style: italic;
158
+ }
159
+ .debug-toggle:hover { color: #a2a2bd; background: none; }
160
+ #debug-log {
161
+ display: none;
162
+ background: #0a0a1a;
163
+ border-radius: 6px;
164
+ padding: 10px;
165
+ font-size: 11px;
166
+ color: #4ecca3;
167
+ font-family: monospace;
168
+ max-height: 160px;
169
+ overflow-y: auto;
170
+ margin-top: 6px;
171
+ text-align: left;
172
+ }
173
 
174
+ /* ---- NOTICE ---- */
175
+ .notice {
176
+ background: #0f2040;
177
+ border-left: 3px solid #e94560;
178
+ border-radius: 0 6px 6px 0;
179
+ padding: 10px 14px;
180
+ margin-bottom: 14px;
181
+ font-size: 13px;
182
+ color: #a2a2bd;
183
+ text-align: left;
184
+ }
185
+ .notice strong { color: #e94560; }
186
  </style>
187
  </head>
188
  <body>
189
+ <div class="container">
190
+ <h1>Bio-Vibe 🎧</h1>
191
+ <p class="subtitle">Âm nhạc trị liệu AI theo Nhịp tim</p>
192
+
193
+ <div id="bpm-display">-- BPM</div>
194
 
195
+ <!-- TABS -->
196
+ <div class="tab-group">
197
+ <button class="tab-btn active" onclick="switchTab('ble')">📡 Kết nối BLE</button>
198
+ <button class="tab-btn" onclick="switchTab('manual')">✏️ Nhập thủ công</button>
199
+ </div>
200
 
201
+ <!-- TAB: BLE -->
202
+ <div id="tab-ble" class="tab-panel active">
203
+ <div class="notice">
204
+ <strong>Lưu ý Xiaomi / Samsung:</strong> Đồng hồ thông minh dùng giao thức riêng,
205
+ không truyền nhịp tim qua BLE chuẩn. Thử kết nối — nếu không ra BPM,
206
+ hãy dùng tab <em>Nhập thủ công</em> hoặc một chest-strap HR monitor (Polar, Garmin…).
207
+ </div>
208
  <div class="button-group">
209
+ <button id="btn-connect">1. Kết nối đồng hồ</button>
210
+ </div>
211
+ </div>
212
+
213
+ <!-- TAB: MANUAL -->
214
+ <div id="tab-manual" class="tab-panel">
215
+ <p style="color:#a2a2bd; font-size:14px; margin-bottom:10px;">
216
+ Nhập BPM từ app đồng hồ (Mi Fitness, Samsung Health…) rồi nhấn Set.
217
+ </p>
218
+ <div class="manual-group">
219
+ <input type="number" id="manual-bpm-input" min="30" max="220" value="75" />
220
+ <button id="btn-manual-set" onclick="setManualBpm()">Set BPM</button>
221
  </div>
222
+ </div>
223
 
224
+ <!-- PLAYBACK CONTROLS -->
225
+ <div class="button-group" style="margin-top:18px;">
226
+ <button id="btn-generate">2. Sinh nhạc AI</button>
227
+ <button id="btn-stop" disabled>3. Dừng nhạc</button>
228
  </div>
229
 
230
+ <div id="status" class="status-info">Chưa có BPM...</div>
231
+
232
+ <!-- DEBUG -->
233
+ <div class="debug-section">
234
+ <button class="debug-toggle" onclick="toggleDebug()">▶ Xem log BLE debug</button>
235
+ <div id="debug-log"></div>
236
+ </div>
237
+ </div>
238
+
239
  <script>
240
+ // ===============================
241
+ // STATE
242
+ // ===============================
243
  let currentBpm = 0;
 
244
  let bpmHistory = [];
245
  let currentAudio = null;
246
+ let debugVisible = false;
247
 
248
+ const bpmDisplay = document.getElementById('bpm-display');
249
+ const statusEl = document.getElementById('status');
250
+ const btnConnect = document.getElementById('btn-connect');
 
251
  const btnGenerate = document.getElementById('btn-generate');
252
+ const btnStop = document.getElementById('btn-stop');
253
+ const debugLog = document.getElementById('debug-log');
254
+
255
+ // ===============================
256
+ // UI HELPERS
257
+ // ===============================
258
+ function setStatus(msg, type = 'info') {
259
+ statusEl.textContent = msg;
260
+ statusEl.className = 'status-' + type;
 
 
 
261
  }
262
 
263
+ function log(msg) {
264
+ const line = document.createElement('div');
265
+ line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg;
266
+ debugLog.appendChild(line);
267
+ debugLog.scrollTop = debugLog.scrollHeight;
268
+ console.log(msg);
269
+ }
 
 
 
 
 
 
270
 
271
+ function toggleDebug() {
272
+ debugVisible = !debugVisible;
273
+ debugLog.style.display = debugVisible ? 'block' : 'none';
274
+ }
275
 
276
+ function switchTab(name) {
277
+ document.querySelectorAll('.tab-btn').forEach((b, i) => {
278
+ b.classList.toggle('active', (i === 0 && name === 'ble') || (i === 1 && name === 'manual'));
279
+ });
280
+ document.getElementById('tab-ble').classList.toggle('active', name === 'ble');
281
+ document.getElementById('tab-manual').classList.toggle('active', name === 'manual');
282
+ }
283
 
284
+ // ===============================
285
+ // BPM SMOOTHING
286
+ // ===============================
287
+ function smoothBpm(val) {
288
+ bpmHistory.push(val);
289
+ if (bpmHistory.length > 5) bpmHistory.shift();
290
+ return Math.round(bpmHistory.reduce((a, b) => a + b, 0) / bpmHistory.length);
291
+ }
292
 
293
+ function applyBpm(raw, source) {
294
+ if (raw < 30 || raw > 220) return false;
295
+ if (currentBpm !== 0 && Math.abs(raw - currentBpm) > 40) return false;
296
 
297
+ const smoothed = smoothBpm(raw);
298
+ currentBpm = smoothed;
299
+ bpmDisplay.textContent = currentBpm + ' BPM';
300
+ bpmDisplay.style.color = '#e94560';
301
+ setStatus('BPM nhận từ: ' + source, 'ok');
302
+ return true;
303
+ }
304
 
305
+ // ===============================
306
+ // MANUAL BPM
307
+ // ===============================
308
+ function setManualBpm() {
309
+ const val = parseInt(document.getElementById('manual-bpm-input').value, 10);
310
+ if (isNaN(val) || val < 30 || val > 220) {
311
+ setStatus('Giá trị BPM không hợp lệ (30–220)', 'err');
312
+ return;
313
+ }
314
+ bpmHistory = [];
315
+ applyBpm(val, 'Nhập thủ công');
316
+ }
317
 
318
+ // ===============================
319
+ // PARSE CHUẨN BLE Heart Rate (0x2A37)
320
+ // ===============================
321
+ function parseHeartRateMeasurement(dataView) {
322
+ /*
323
+ * Byte 0: Flags
324
+ * bit 0 = 0 → Heart Rate Value là UINT8 (byte 1)
325
+ * bit 0 = 1 → Heart Rate Value là UINT16 (bytes 1-2, little-endian)
326
+ */
327
+ const flags = dataView.getUint8(0);
328
+ const is16bit = (flags & 0x01) !== 0;
329
+ return is16bit
330
+ ? dataView.getUint16(1, true)
331
+ : dataView.getUint8(1);
332
+ }
333
 
334
+ // ===============================
335
+ // HANDLE INCOMING CHARACTERISTIC DATA
336
+ // ===============================
337
+ function handleCharData(event, uuid, serviceUuid) {
338
+ const dv = event.target.value;
339
+ const raw = [];
340
+ for (let i = 0; i < dv.byteLength; i++) raw.push(dv.getUint8(i));
341
+ log(`UUID ${uuid.substring(0,8)}… DATA: [${raw.join(', ')}]`);
342
+
343
+ const HR_MEASUREMENT_UUID = '00002a37-0000-1000-8000-00805f9b34fb';
344
+
345
+ // 1. Nếu đúng UUID chuẩn Heart Rate Measurement → parse theo spec
346
+ if (uuid === HR_MEASUREMENT_UUID) {
347
+ if (dv.byteLength >= 2) {
348
+ const bpm = parseHeartRateMeasurement(dv);
349
+ log(`Standard HR parse → ${bpm} BPM`);
350
+ applyBpm(bpm, 'HR chuẩn BLE');
351
+ }
352
+ return;
353
+ }
354
 
355
+ // 2. Fallback heuristic cho proprietary chars
356
+ // Duyệt từng byte, lấy byte đầu tiên nằm trong [40, 180]
357
+ for (let i = 0; i < raw.length; i++) {
358
+ const v = raw[i];
359
+ if (v >= 40 && v <= 180) {
360
+ log(`Heuristic byte[${i}] = ${v} BPM`);
361
+ applyBpm(v, `Heuristic (${uuid.substring(0,8)}…)`);
362
  break;
363
  }
364
  }
365
  }
366
 
367
+ // ===============================
368
+ // BLE CONNECT — auto scan tất cả services
369
+ // ===============================
370
  btnConnect.addEventListener('click', async () => {
371
+ if (!navigator.bluetooth) {
372
+ setStatus('Trình duyệt không hỗ trợ Web Bluetooth!', 'err');
373
+ return;
374
+ }
375
+
376
  try {
377
+ setStatus('Đang mở hộp thoại BLE…', 'info');
378
+ log('Bắt đầu scan BLE...');
379
 
380
  const device = await navigator.bluetooth.requestDevice({
381
  acceptAllDevices: true,
382
  optionalServices: [
383
+ // Chuẩn BLE
384
+ 'heart_rate',
385
  'battery_service',
386
  'device_information',
387
+ 'generic_access',
388
+ // Xiaomi Mi Band 4/5/6/7 — proprietary
389
+ '0000fee0-0000-1000-8000-00805f9b34fb',
390
+ '0000fee1-0000-1000-8000-00805f9b34fb',
391
+ // Xiaomi Mi Band 7 / Amazfit
392
+ '0000181d-0000-1000-8000-00805f9b34fb',
393
+ // Samsung Galaxy Watch — proprietary health
394
+ '0000fd00-0000-1000-8000-00805f9b34fb',
395
+ // Generic HR secondary
396
+ '0000180d-0000-1000-8000-00805f9b34fb',
397
  ]
398
  });
399
 
400
+ log(`Device: ${device.name || '(no name)'} id=${device.id}`);
401
+ setStatus('Đang kết nối GATT…', 'info');
402
+
403
+ const server = await device.gatt.connect();
404
  const services = await server.getPrimaryServices();
405
+ log(`Tìm thấy ${services.length} service(s)`);
406
 
407
+ let subscribed = 0;
 
408
 
409
+ for (const svc of services) {
410
+ log(`Service: ${svc.uuid}`);
411
+ let chars;
412
+ try {
413
+ chars = await svc.getCharacteristics();
414
+ } catch (e) {
415
+ log(` Không đọc được characteristics: ${e.message}`);
416
+ continue;
417
+ }
418
 
419
+ for (const char of chars) {
420
+ const props = char.properties;
421
+ log(` Char: ${char.uuid} [notify=${props.notify} indicate=${props.indicate} read=${props.read}]`);
422
 
423
+ if (props.notify || props.indicate) {
424
  try {
425
  await char.startNotifications();
426
+ char.addEventListener('characteristicvaluechanged',
427
+ (ev) => handleCharData(ev, char.uuid, svc.uuid));
428
+ log(` ✅ Subscribed: ${char.uuid}`);
429
+ subscribed++;
430
+ } catch (e) {
431
+ log(` ❌ Subscribe failed: ${e.message}`);
 
 
 
432
  }
433
  }
434
  }
435
  }
436
 
437
+ btnConnect.textContent = '✓ BLE Connected';
438
+ btnConnect.disabled = true;
439
 
440
+ if (subscribed === 0) {
441
+ setStatus('Kết nối OK nhưng không có characteristic nào để subscribe. Thử tab Nhập thủ công.', 'err');
442
+ } else {
443
+ setStatus(`Đã subscribe ${subscribed} characteristic(s), đang chờ BPM…`, 'ok');
444
+ }
445
+
446
+ } catch (err) {
447
+ log(`Lỗi: ${err.message}`);
448
+ setStatus('Lỗi BLE: ' + err.message, 'err');
449
  }
450
  });
451
 
452
+ // ===============================
453
+ // GENERATE MUSIC
454
+ // ===============================
455
  btnGenerate.addEventListener('click', async () => {
456
  if (currentBpm === 0) {
457
+ alert('Chưa có BPM! Kết nối đồng hồ hoặc nhập thủ công.');
458
  return;
459
  }
460
 
 
461
  if (currentAudio) {
462
  currentAudio.pause();
463
  currentAudio.currentTime = 0;
464
+ bpmDisplay.style.animation = 'none';
465
  }
466
 
467
+ setStatus(`Đang gửi ${currentBpm} BPM cho AI`, 'info');
468
+ btnGenerate.textContent = 'AI đang sáng tác…';
469
+ btnGenerate.disabled = true;
470
+ btnStop.disabled = true;
471
 
472
  try {
473
+ const res = await fetch('/generate-vibe', {
474
+ method: 'POST',
475
  headers: { 'Content-Type': 'application/json' },
476
+ body: JSON.stringify({ bpm: currentBpm })
477
  });
478
+ const data = await res.json();
479
 
480
+ if (data.status === 'success') {
 
 
481
  currentAudio = new Audio(data.audio_url);
482
  currentAudio.play();
483
+ bpmDisplay.style.animation = 'pulse 1s infinite';
484
+ setStatus('🎵 Đang phát nhạc…', 'ok');
 
 
485
  btnStop.disabled = false;
486
 
487
  currentAudio.onended = () => {
488
+ bpmDisplay.style.animation = 'none';
489
  btnStop.disabled = true;
490
+ setStatus('Nhạc đã kết thúc.', 'info');
491
  };
 
492
  } else {
493
+ setStatus('Lỗi AI: ' + (data.message || 'unknown'), 'err');
494
  }
495
+ } catch (err) {
496
+ log('Generate error: ' + err.message);
497
+ setStatus('Mất kết nối server!', 'err');
 
498
  } finally {
499
+ btnGenerate.textContent = '2. Sinh nhạc AI';
500
+ btnGenerate.disabled = false;
501
  }
502
  });
503
 
504
+ // ===============================
505
  // STOP MUSIC
506
+ // ===============================
507
  btnStop.addEventListener('click', () => {
508
  if (currentAudio) {
509
  currentAudio.pause();
510
+ currentAudio.currentTime = 0;
511
+ bpmDisplay.style.animation = 'none';
512
+ btnStop.disabled = true;
513
+ setStatus('Đã dừng nhạc.', 'info');
 
514
  }
515
  });
516
  </script>