bloby-bot 0.18.7 → 0.18.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,9 +6,7 @@
6
6
  // ── Styles ──
7
7
  var style = document.createElement('style');
8
8
  style.textContent = [
9
- '#bloby-widget-bubble{transition:transform .15s ease;-webkit-tap-highlight-color:transparent}',
10
- '#bloby-widget-bubble:hover{transform:scale(1.1)}',
11
- '#bloby-widget-bubble:active{transform:scale(0.95)}',
9
+ '#bloby-widget-bubble{-webkit-tap-highlight-color:transparent}',
12
10
  '#bloby-widget-backdrop{position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,0.4);opacity:0;transition:opacity .2s ease;pointer-events:none}',
13
11
  '#bloby-widget-backdrop.open{opacity:1;pointer-events:auto}',
14
12
  '#bloby-widget-panel{position:fixed;top:0;right:0;bottom:0;z-index:99999;width:' + PANEL_WIDTH + ';max-width:100vw;transform:translateX(100%);transition:transform .25s cubic-bezier(.4,0,.2,1);box-shadow:-4px 0 24px rgba(0,0,0,0.3);border-left:1px solid #3a3a3a;overflow:hidden}',
@@ -33,7 +31,7 @@
33
31
  panel.appendChild(iframe);
34
32
 
35
33
  // ══════════════════════════════════════════════════════════════════
36
- // ── Blob Sprite Sheet Config ─────────────────────────────────────
34
+ // ── Blob Sprite Sheet Config (splash only) ───────────────────────
37
35
  // ══════════════════════════════════════════════════════════════════
38
36
 
39
37
  var COLS = 16;
@@ -42,13 +40,11 @@
42
40
  var DISPLAY_H = 58;
43
41
  var DISPLAY_W = DISPLAY_H * (FRAME_W / FRAME_H);
44
42
 
45
- // Frame ranges (0-indexed)
46
43
  var IDLE_START = 0, IDLE_END = 29;
47
44
  var MELT_START = 30, MELT_END = 52;
48
45
  var TRAVEL_START = 52, TRAVEL_END = 84;
49
46
  var REFORM_START = 84, REFORM_END = 191;
50
47
 
51
- // Timing
52
48
  var FPS = 29;
53
49
  var FRAME_MS = 1000 / FPS;
54
50
  var IDLE_FPS = 22;
@@ -63,47 +59,47 @@
63
59
  var BUBBLE_SIZE = 60;
64
60
  var BUBBLE_MARGIN = 24;
65
61
 
66
- // ── Badge element (HTML, not canvas — avoids border-radius clipping) ──
62
+ // ── Badge element ──
67
63
  var badgeEl = document.createElement('div');
68
64
  badgeEl.id = 'bloby-widget-badge';
69
65
  badgeEl.style.cssText = 'display:none;position:fixed;bottom:' + (BUBBLE_MARGIN + BUBBLE_SIZE - 14) + 'px;right:' + (BUBBLE_MARGIN - 5) + 'px;z-index:99999;min-width:20px;height:20px;border-radius:10px;background:#F04D68;color:#fff;font:bold 11px -apple-system,BlinkMacSystemFont,sans-serif;text-align:center;line-height:20px;padding:0 5px;box-sizing:border-box;pointer-events:none;';
70
66
  document.body.appendChild(badgeEl);
71
67
 
72
68
  // ══════════════════════════════════════════════════════════════════
73
- // ── Headphones Sprite Sheet Config ───────────────────────────────
69
+ // ── Headphones Sprite Sheet Config (v2 — bubble + recording) ─────
74
70
  // ══════════════════════════════════════════════════════════════════
75
71
 
76
72
  var HP_COLS = 16;
77
73
  var HP_FRAME_W = 126;
78
74
  var HP_FRAME_H = 84;
79
- var HP_DISPLAY_H = 56;
80
- var HP_DISPLAY_W = HP_DISPLAY_H * (HP_FRAME_W / HP_FRAME_H);
75
+ // Bubble display: fill 60px width exactly
76
+ var HP_BUBBLE_H = 40;
77
+ var HP_BUBBLE_W = HP_BUBBLE_H * (HP_FRAME_W / HP_FRAME_H); // 60
81
78
 
79
+ // v2 frame ranges
80
+ var HP_IDLE_FRAME = 10;
82
81
  var HP_ACTIVATE_START = 11, HP_ACTIVATE_END = 40;
83
- var HP_RECORD_START = 41, HP_RECORD_END = 85;
84
- var HP_DEACTIVATE_START = 85, HP_DEACTIVATE_END = 191;
85
- var HP_FPS = 24;
86
- var HP_FRAME_MS = 1000 / HP_FPS;
82
+ var HP_RECORD_START = 40, HP_RECORD_END = 85;
87
83
 
88
- // Expanded canvas for headphones mode
89
- var HP_CANVAS_W = 90;
90
- var HP_CANVAS_H = 66;
84
+ // v2 FPS: 2x speed for activate/deactivate, normal for recording
85
+ var HP_BASE_FPS = 24;
86
+ var HP_FAST_FPS = 48;
91
87
 
92
- // Long-press threshold (ms) — 1 second, like WhatsApp
93
- var HP_HOLD_MS = 1000;
88
+ // Long-press threshold
89
+ var HP_HOLD_MS = 500;
94
90
 
95
- // ── Bubble CSS (shared — DRY) ──
96
- var BUBBLE_CSS = 'position:fixed;bottom:' + BUBBLE_MARGIN + 'px;right:' + BUBBLE_MARGIN + 'px;width:' + BUBBLE_SIZE + 'px;height:' + BUBBLE_SIZE + 'px;z-index:99998;cursor:pointer;border-radius:50%;transition:transform .15s ease;-webkit-tap-highlight-color:transparent;touch-action:none;';
91
+ // ── Bubble CSS (shared) ──
92
+ var BUBBLE_CSS = 'position:fixed;bottom:' + BUBBLE_MARGIN + 'px;right:' + BUBBLE_MARGIN + 'px;width:' + BUBBLE_SIZE + 'px;height:' + BUBBLE_SIZE + 'px;z-index:99998;cursor:pointer;border-radius:50%;-webkit-tap-highlight-color:transparent;touch-action:none;';
97
93
 
98
94
  // ── Easing ──
99
95
  function easeInOutCubic(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2; }
100
96
 
101
- // ── Splash guard (skip on soft refresh within same tab) ──
97
+ // ── Splash guard ──
102
98
  var SPLASH_KEY = 'bloby_splash_played';
103
99
  var splashSeen = false;
104
100
  try { splashSeen = sessionStorage.getItem(SPLASH_KEY) === '1'; } catch(e) {}
105
101
 
106
- // ── Canvas setup (starts fullscreen for splash) ──
102
+ // ── Canvas setup ──
107
103
  var canvas = document.createElement('canvas');
108
104
  canvas.id = 'bloby-widget-bubble';
109
105
  canvas.setAttribute('role', 'button');
@@ -117,7 +113,7 @@
117
113
  var dpr = window.devicePixelRatio || 1;
118
114
  var W = 0, H = 0;
119
115
 
120
- // ── Canvas phase: 'splash' | 'transitioning' | 'bubble' | 'headphones' | 'disabled' ──
116
+ // ── Canvas phase: 'splash' | 'transitioning' | 'bubble' | 'disabled' ──
121
117
  var canvasPhase = 'splash';
122
118
  var canvasCreatedAt = Date.now();
123
119
  var appReady = false;
@@ -126,14 +122,9 @@
126
122
 
127
123
  function resizeCanvas() {
128
124
  if (canvasPhase === 'bubble') {
129
- W = BUBBLE_SIZE;
130
- H = BUBBLE_SIZE;
131
- } else if (canvasPhase === 'headphones') {
132
- W = HP_CANVAS_W;
133
- H = HP_CANVAS_H;
125
+ W = BUBBLE_SIZE; H = BUBBLE_SIZE;
134
126
  } else {
135
- W = window.innerWidth;
136
- H = window.innerHeight;
127
+ W = window.innerWidth; H = window.innerHeight;
137
128
  }
138
129
  canvas.width = W * dpr;
139
130
  canvas.height = H * dpr;
@@ -150,7 +141,7 @@
150
141
  }
151
142
  });
152
143
 
153
- // ── Blob animation state ──
144
+ // ── Blob animation state (splash only) ──
154
145
  var spriteSheet = null;
155
146
  var animState = 'loading';
156
147
  var currentFrame = 0;
@@ -161,22 +152,22 @@
161
152
  var travelAngle = 0;
162
153
  var travelDrawAngle = 0;
163
154
  var travelFlip = 1;
164
-
165
155
  var center = { x: 0, y: 0 };
166
156
  var travelA = { x: 0, y: 0 };
167
157
  var target = { x: 0, y: 0 };
168
158
 
169
- // ── Headphones animation state ──
159
+ // ── Headphones animation state (bubble) ──
170
160
  var hpSpriteSheet = null;
171
- var hpState = 'none'; // none | activating | recording | deactivating | activating_then_deactivate
172
- var hpFrame = 0;
161
+ // idle | activating | activating_then_deactivate | recording | deactivating
162
+ var hpState = 'idle';
163
+ var hpFrame = HP_IDLE_FRAME;
173
164
  var hpPingPong = 1;
174
165
  var hpLastFrameTime = 0;
175
166
 
176
167
  // ── Unread badge ──
177
168
  var unreadCount = 0;
178
169
 
179
- // ── Headphones mic recording state ──
170
+ // ── Mic recording state ──
180
171
  var hpMediaRecorder = null;
181
172
  var hpAudioChunks = [];
182
173
  var hpStream = null;
@@ -198,69 +189,65 @@
198
189
  center.y = Math.round(H / 2);
199
190
  currentFrame = IDLE_START;
200
191
  animState = 'idle';
201
-
202
- // Hide the HTML splash fallback
203
192
  var splash = document.getElementById('splash');
204
193
  if (splash) splash.style.display = 'none';
205
-
206
194
  onDone();
207
195
  };
208
- img.onerror = function () {
209
- if (onFail) onFail();
210
- };
196
+ img.onerror = function () { if (onFail) onFail(); };
211
197
  img.src = '/spritesheet.webp';
212
198
  }
213
199
 
214
- // ── Load headphones sprite sheet (lazy, called after bubble ready) ──
200
+ // ── Load headphones sprite sheet ──
215
201
  function loadHpSprite(cb) {
216
202
  if (hpSpriteSheet) { if (cb) cb(); return; }
217
203
  var img = new Image();
218
204
  img.onload = function () { hpSpriteSheet = img; if (cb) cb(); };
219
- img.onerror = function () { console.error('[widget] headphones spritesheet failed to load'); };
205
+ img.onerror = function () { console.error('[widget] headphones sprite failed'); };
220
206
  img.src = '/headphones_spritesheet.webp';
221
207
  }
222
208
 
223
- // ── Trigger blob move ──
209
+ // ── Trigger blob move (splash) ──
224
210
  function moveTo(tx, ty) {
225
211
  if (animState !== 'idle') return;
226
- target.x = tx;
227
- target.y = ty;
228
- travelA.x = center.x;
229
- travelA.y = center.y;
230
-
231
- var dx = target.x - center.x;
232
- var dy = target.y - center.y;
212
+ target.x = tx; target.y = ty;
213
+ travelA.x = center.x; travelA.y = center.y;
214
+ var dx = target.x - center.x, dy = target.y - center.y;
233
215
  var dist = Math.sqrt(dx * dx + dy * dy);
234
-
235
216
  travelAngle = Math.atan2(dy, dx);
236
217
  travelDrawAngle = travelAngle;
237
218
  travelFlip = 1;
238
219
  if (travelDrawAngle > Math.PI / 2) { travelDrawAngle -= Math.PI; travelFlip = -1; }
239
220
  else if (travelDrawAngle < -Math.PI / 2) { travelDrawAngle += Math.PI; travelFlip = -1; }
240
221
  travelDuration = Math.min(TRAVEL_MAX, Math.max(TRAVEL_MIN, dist / TRAVEL_PX_PER_MS));
241
-
242
222
  currentFrame = MELT_START;
243
223
  lastFrameTime = performance.now();
244
224
  animState = 'melting';
245
225
  }
246
226
 
247
227
  // ══════════════════════════════════════════════════════════════════
248
- // ── Update (blob + headphones) ───────────────────────────────────
228
+ // ── Update ───────────────────────────────────────────────────────
249
229
  // ══════════════════════════════════════════════════════════════════
250
230
 
231
+ function hpFps() {
232
+ if (hpState === 'activating' || hpState === 'activating_then_deactivate' || hpState === 'deactivating') return HP_FAST_FPS;
233
+ return HP_BASE_FPS;
234
+ }
235
+
251
236
  function update(now) {
252
- // ── Headphones animation ──
253
- if (hpState !== 'none') {
237
+ // ── Headphones animation (bubble mode) ──
238
+ if (canvasPhase === 'bubble' && hpState !== 'idle') {
239
+ var hpInterval = 1000 / hpFps();
254
240
  var hpDelta = now - hpLastFrameTime;
255
- if (hpDelta >= HP_FRAME_MS) {
256
- hpLastFrameTime = now - (hpDelta % HP_FRAME_MS);
241
+ if (hpDelta >= hpInterval) {
242
+ hpLastFrameTime = now - (hpDelta % hpInterval);
257
243
 
258
244
  if (hpState === 'activating' || hpState === 'activating_then_deactivate') {
259
245
  hpFrame++;
260
246
  if (hpFrame > HP_ACTIVATE_END) {
261
247
  if (hpState === 'activating_then_deactivate') {
248
+ // Reverse back to idle
262
249
  hpState = 'deactivating';
263
- hpFrame = HP_DEACTIVATE_START;
250
+ hpFrame = HP_ACTIVATE_END;
264
251
  } else {
265
252
  hpState = 'recording';
266
253
  hpFrame = HP_RECORD_START;
@@ -272,28 +259,26 @@
272
259
  if (hpFrame >= HP_RECORD_END) { hpFrame = HP_RECORD_END; hpPingPong = -1; }
273
260
  else if (hpFrame <= HP_RECORD_START) { hpFrame = HP_RECORD_START; hpPingPong = 1; }
274
261
  } else if (hpState === 'deactivating') {
275
- hpFrame++;
276
- if (hpFrame > HP_DEACTIVATE_END) {
277
- hpState = 'none';
278
- endHpCanvasMode();
262
+ // v2: reverse 40→11
263
+ hpFrame--;
264
+ if (hpFrame < HP_ACTIVATE_START) {
265
+ hpState = 'idle';
266
+ hpFrame = HP_IDLE_FRAME;
279
267
  }
280
268
  }
281
269
  }
282
- return; // Skip blob update while headphones active
270
+ return; // skip blob update
283
271
  }
284
272
 
285
- // ── Blob animation ──
286
- // Smooth position during travel (every rAF)
273
+ // ── Blob animation (splash/transition only) ──
287
274
  if (animState === 'traveling') {
288
275
  var elapsed = now - travelStartTime;
289
276
  var progress = Math.min(1, elapsed / travelDuration);
290
277
  var e = easeInOutCubic(progress);
291
278
  center.x = travelA.x + (target.x - travelA.x) * e;
292
279
  center.y = travelA.y + (target.y - travelA.y) * e;
293
-
294
280
  if (progress >= 1) {
295
- center.x = target.x;
296
- center.y = target.y;
281
+ center.x = target.x; center.y = target.y;
297
282
  animState = 'reforming';
298
283
  currentFrame = REFORM_START;
299
284
  lastFrameTime = now;
@@ -301,7 +286,6 @@
301
286
  }
302
287
  }
303
288
 
304
- // Sprite frame advance (at configured FPS)
305
289
  var frameInterval = animState === 'idle' ? IDLE_FRAME_MS
306
290
  : animState === 'reforming' ? REFORM_FRAME_MS
307
291
  : FRAME_MS;
@@ -315,11 +299,7 @@
315
299
  else if (currentFrame <= IDLE_START) { currentFrame = IDLE_START; idleDirection = 1; }
316
300
  } else if (animState === 'melting') {
317
301
  currentFrame++;
318
- if (currentFrame > MELT_END) {
319
- animState = 'traveling';
320
- currentFrame = TRAVEL_START;
321
- travelStartTime = now;
322
- }
302
+ if (currentFrame > MELT_END) { animState = 'traveling'; currentFrame = TRAVEL_START; travelStartTime = now; }
323
303
  } else if (animState === 'traveling') {
324
304
  currentFrame++;
325
305
  if (currentFrame > TRAVEL_END) currentFrame = TRAVEL_START;
@@ -329,73 +309,37 @@
329
309
  animState = 'idle';
330
310
  currentFrame = IDLE_START;
331
311
  idleDirection = 1;
332
- // Transition canvas to bubble mode
333
312
  if (canvasPhase === 'transitioning') {
334
- canvasPhase = 'bubble';
335
- canvas.style.cssText = BUBBLE_CSS;
336
- W = BUBBLE_SIZE;
337
- H = BUBBLE_SIZE;
338
- canvas.width = BUBBLE_SIZE * dpr;
339
- canvas.height = BUBBLE_SIZE * dpr;
340
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
341
- center.x = BUBBLE_SIZE / 2;
342
- center.y = BUBBLE_SIZE / 2;
343
- // Mark splash as played for this session
344
- try { sessionStorage.setItem(SPLASH_KEY, '1'); } catch(e) {}
345
- // Preload headphones sprite in background
346
- loadHpSprite();
347
- if (hideAfterTransition) {
348
- canvas.style.display = 'none';
349
- }
313
+ enterBubbleMode();
350
314
  }
351
315
  }
352
316
  }
353
317
  }
354
318
 
355
319
  // ══════════════════════════════════════════════════════════════════
356
- // ── Draw (blob + headphones) ─────────────────────────────────────
320
+ // ── Draw ─────────────────────────────────────────────────────────
357
321
  // ══════════════════════════════════════════════════════════════════
358
322
 
359
323
  function drawBlobFrame(frame, x, y, rotate, flipX) {
360
324
  if (!spriteSheet) return;
361
- var col = frame % COLS;
362
- var row = Math.floor(frame / COLS);
363
- var sx = col * FRAME_W;
364
- var sy = row * FRAME_H;
365
-
325
+ var col = frame % COLS, row = Math.floor(frame / COLS);
366
326
  ctx.save();
367
327
  ctx.translate(x, y);
368
328
  if (rotate) ctx.rotate(rotate);
369
329
  if (flipX === -1) ctx.scale(-1, 1);
370
330
  ctx.imageSmoothingEnabled = true;
371
331
  ctx.imageSmoothingQuality = 'high';
372
- ctx.drawImage(
373
- spriteSheet,
374
- sx, sy, FRAME_W, FRAME_H,
375
- -DISPLAY_W / 2, -DISPLAY_H / 2,
376
- DISPLAY_W, DISPLAY_H
377
- );
332
+ ctx.drawImage(spriteSheet, col * FRAME_W, row * FRAME_H, FRAME_W, FRAME_H, -DISPLAY_W / 2, -DISPLAY_H / 2, DISPLAY_W, DISPLAY_H);
378
333
  ctx.restore();
379
334
  }
380
335
 
381
336
  function drawHpFrame(frame) {
382
337
  if (!hpSpriteSheet) return;
383
- var col = frame % HP_COLS;
384
- var row = Math.floor(frame / HP_COLS);
385
- var sx = col * HP_FRAME_W;
386
- var sy = row * HP_FRAME_H;
387
-
388
- ctx.save();
389
- ctx.translate(W / 2, H / 2);
338
+ var col = frame % HP_COLS, row = Math.floor(frame / HP_COLS);
339
+ var dx = (W - HP_BUBBLE_W) / 2, dy = (H - HP_BUBBLE_H) / 2;
390
340
  ctx.imageSmoothingEnabled = true;
391
341
  ctx.imageSmoothingQuality = 'high';
392
- ctx.drawImage(
393
- hpSpriteSheet,
394
- sx, sy, HP_FRAME_W, HP_FRAME_H,
395
- -HP_DISPLAY_W / 2, -HP_DISPLAY_H / 2,
396
- HP_DISPLAY_W, HP_DISPLAY_H
397
- );
398
- ctx.restore();
342
+ ctx.drawImage(hpSpriteSheet, col * HP_FRAME_W, row * HP_FRAME_H, HP_FRAME_W, HP_FRAME_H, dx, dy, HP_BUBBLE_W, HP_BUBBLE_H);
399
343
  }
400
344
 
401
345
  function updateBadge() {
@@ -407,28 +351,25 @@
407
351
  badgeEl.style.display = 'block';
408
352
  }
409
353
 
410
- function draw(now) {
354
+ function draw() {
411
355
  ctx.clearRect(0, 0, W, H);
412
356
 
413
- // Headphones mode
414
- if (hpState !== 'none') {
357
+ // Bubble mode → headphones sprite
358
+ if (canvasPhase === 'bubble') {
415
359
  drawHpFrame(hpFrame);
416
360
  return;
417
361
  }
418
362
 
419
- // Blob mode
363
+ // Splash / transition → blob sprite
420
364
  if (!spriteSheet || animState === 'loading') return;
421
-
422
365
  if (animState === 'melting') {
423
366
  var p = (currentFrame - MELT_START) / (MELT_END - MELT_START);
424
- var eased = p * p;
425
- drawBlobFrame(currentFrame, center.x, center.y, travelDrawAngle * eased, travelFlip);
367
+ drawBlobFrame(currentFrame, center.x, center.y, travelDrawAngle * p * p, travelFlip);
426
368
  } else if (animState === 'traveling') {
427
369
  drawBlobFrame(currentFrame, center.x, center.y, travelDrawAngle, travelFlip);
428
370
  } else if (animState === 'reforming') {
429
371
  var p2 = (currentFrame - REFORM_START) / (REFORM_END - REFORM_START);
430
- var eased2 = 1 - (1 - p2) * (1 - p2);
431
- drawBlobFrame(currentFrame, center.x, center.y, travelDrawAngle * (1 - eased2));
372
+ drawBlobFrame(currentFrame, center.x, center.y, travelDrawAngle * (1 - (1 - (1 - p2) * (1 - p2))));
432
373
  } else {
433
374
  drawBlobFrame(currentFrame, center.x, center.y);
434
375
  }
@@ -436,73 +377,49 @@
436
377
 
437
378
  // ── Animation loop ──
438
379
  function loop(now) {
439
- if (animState !== 'loading' || hpState !== 'none') {
380
+ if (animState !== 'loading' || canvasPhase === 'bubble') {
440
381
  update(now);
441
- draw(now);
382
+ draw();
442
383
  }
443
384
  requestAnimationFrame(loop);
444
385
  }
445
386
 
446
387
  // ══════════════════════════════════════════════════════════════════
447
- // ── Headphones Canvas Mode ───────────────────────────────────────
388
+ // ── Bubble Mode ──────────────────────────────────────────────────
448
389
  // ══════════════════════════════════════════════════════════════════
449
390
 
450
- function enterHpCanvasMode() {
451
- canvasPhase = 'headphones';
452
- badgeEl.style.display = 'none';
453
- // Expand canvas, keeping center roughly aligned with the bubble center
454
- var bubbleCenterFromRight = BUBBLE_MARGIN + BUBBLE_SIZE / 2;
455
- var bubbleCenterFromBottom = BUBBLE_MARGIN + BUBBLE_SIZE / 2;
456
- var hpRight = Math.round(bubbleCenterFromRight - HP_CANVAS_W / 2);
457
- var hpBottom = Math.round(bubbleCenterFromBottom - HP_CANVAS_H / 2);
458
- canvas.style.cssText = 'position:fixed;bottom:' + hpBottom + 'px;right:' + hpRight + 'px;width:' + HP_CANVAS_W + 'px;height:' + HP_CANVAS_H + 'px;z-index:99998;cursor:pointer;border-radius:12px;pointer-events:auto;touch-action:none;';
459
- W = HP_CANVAS_W;
460
- H = HP_CANVAS_H;
461
- canvas.width = HP_CANVAS_W * dpr;
462
- canvas.height = HP_CANVAS_H * dpr;
463
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
464
-
465
- hpState = 'activating';
466
- hpFrame = HP_ACTIVATE_START;
467
- hpLastFrameTime = performance.now();
468
- }
469
-
470
- function endHpCanvasMode() {
391
+ function enterBubbleMode() {
471
392
  canvasPhase = 'bubble';
472
393
  canvas.style.cssText = BUBBLE_CSS;
473
- W = BUBBLE_SIZE;
474
- H = BUBBLE_SIZE;
394
+ W = BUBBLE_SIZE; H = BUBBLE_SIZE;
475
395
  canvas.width = BUBBLE_SIZE * dpr;
476
396
  canvas.height = BUBBLE_SIZE * dpr;
477
397
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
478
- center.x = BUBBLE_SIZE / 2;
479
- center.y = BUBBLE_SIZE / 2;
480
- animState = 'idle';
481
- currentFrame = IDLE_START;
482
- idleDirection = 1;
398
+ hpState = 'idle';
399
+ hpFrame = HP_IDLE_FRAME;
400
+ try { sessionStorage.setItem(SPLASH_KEY, '1'); } catch(e) {}
401
+ loadHpSprite();
402
+ if (hideAfterTransition) canvas.style.display = 'none';
483
403
  }
484
404
 
485
405
  // ══════════════════════════════════════════════════════════════════
486
- // ── Headphones Mic Recording ─────────────────────────────────────
406
+ // ── Mic Recording ────────────────────────────────────────────────
487
407
  // ══════════════════════════════════════════════════════════════════
488
408
 
489
- /** Check mic permission state without triggering a prompt */
490
409
  function checkMicPermission() {
491
410
  if (hpMicPermitted) return Promise.resolve('granted');
492
411
  if (navigator.permissions && navigator.permissions.query) {
493
- return navigator.permissions.query({ name: 'microphone' }).then(function(status) {
494
- if (status.state === 'granted') { hpMicPermitted = true; return 'granted'; }
495
- return status.state; // 'prompt' or 'denied'
412
+ return navigator.permissions.query({ name: 'microphone' }).then(function(s) {
413
+ if (s.state === 'granted') { hpMicPermitted = true; return 'granted'; }
414
+ return s.state;
496
415
  }).catch(function() { return 'unknown'; });
497
416
  }
498
417
  return Promise.resolve('unknown');
499
418
  }
500
419
 
501
- /** Start mic capture (shared by initial recording and re-activation) */
502
420
  function startMicCapture() {
503
421
  if (hpWhisperEnabled) {
504
422
  navigator.mediaDevices.getUserMedia({ audio: true }).then(function(stream) {
505
- // If user released while getUserMedia was pending, clean up
506
423
  if (!hpPointerDown) {
507
424
  stream.getTracks().forEach(function(t) { t.stop(); });
508
425
  if (hpState === 'activating') hpState = 'activating_then_deactivate';
@@ -513,9 +430,7 @@
513
430
  ? 'audio/webm;codecs=opus' : 'audio/webm';
514
431
  var recorder = new MediaRecorder(stream, { mimeType: mimeType });
515
432
  hpAudioChunks = [];
516
- recorder.ondataavailable = function(ev) {
517
- if (ev.data.size > 0) hpAudioChunks.push(ev.data);
518
- };
433
+ recorder.ondataavailable = function(ev) { if (ev.data.size > 0) hpAudioChunks.push(ev.data); };
519
434
  hpMediaRecorder = recorder;
520
435
  recorder.start();
521
436
  }).catch(function(err) {
@@ -529,62 +444,45 @@
529
444
  recognition.lang = navigator.language || 'en-US';
530
445
  hpSpeechTranscript = '';
531
446
  hpSpeechInstance = recognition;
532
-
533
- recognition.onresult = function(event) {
534
- var last = event.results[event.results.length - 1];
535
- hpSpeechTranscript = last[0].transcript;
536
- };
447
+ recognition.onresult = function(event) { hpSpeechTranscript = event.results[event.results.length - 1][0].transcript; };
537
448
  recognition.onend = function() { hpSpeechInstance = null; };
538
- recognition.onerror = function(event) {
539
- console.error('[widget] speech error:', event.error);
540
- hpSpeechInstance = null;
541
- };
542
- try { recognition.start(); } catch(e) {
543
- console.error('[widget] speech start failed:', e);
544
- }
449
+ recognition.onerror = function(ev) { console.error('[widget] speech error:', ev.error); hpSpeechInstance = null; };
450
+ try { recognition.start(); } catch(e) {}
545
451
  }
546
452
  }
547
453
 
548
- /** Entry point: permission check → sprite load → begin recording */
549
454
  function startHpRecording() {
550
- // Need at least one voice backend
551
455
  if (!hpWhisperEnabled && !hpWebSpeechCtor) return;
552
456
 
553
457
  checkMicPermission().then(function(permState) {
554
458
  if (permState === 'denied') return;
555
-
556
459
  if (permState === 'prompt' || permState === 'unknown') {
557
- // Request permission — this may show a dialog
558
460
  navigator.mediaDevices.getUserMedia({ audio: true }).then(function(testStream) {
559
461
  testStream.getTracks().forEach(function(t) { t.stop(); });
560
462
  hpMicPermitted = true;
561
- // User may have released to click Allow — check before proceeding
562
463
  if (!hpPointerDown) return;
563
- finishStartHpRecording();
564
- }).catch(function() { /* denied */ });
464
+ beginHpAnimation();
465
+ }).catch(function() {});
565
466
  return;
566
467
  }
567
-
568
- // Permission already granted
569
- finishStartHpRecording();
468
+ beginHpAnimation();
570
469
  });
571
470
  }
572
471
 
573
- function finishStartHpRecording() {
472
+ function beginHpAnimation() {
574
473
  if (!hpPointerDown) return;
575
474
  if (!hpSpriteSheet) {
576
- loadHpSprite(function() {
577
- if (!hpPointerDown) return;
578
- enterHpCanvasMode();
579
- startMicCapture();
580
- });
475
+ loadHpSprite(function() { if (hpPointerDown) beginHpAnimation(); });
581
476
  return;
582
477
  }
583
- enterHpCanvasMode();
478
+ badgeEl.style.display = 'none';
479
+ hpState = 'activating';
480
+ hpFrame = HP_ACTIVATE_START;
481
+ hpPingPong = 1;
482
+ hpLastFrameTime = performance.now();
584
483
  startMicCapture();
585
484
  }
586
485
 
587
- /** Stop recording and optionally send audio to iframe */
588
486
  function stopHpRecording(cancelled) {
589
487
  if (cancelled) {
590
488
  if (hpStream) { hpStream.getTracks().forEach(function(t) { t.stop(); }); hpStream = null; }
@@ -593,25 +491,18 @@
593
491
  if (hpSpeechInstance) { try { hpSpeechInstance.abort(); } catch(e) {} hpSpeechInstance = null; }
594
492
  hpSpeechTranscript = '';
595
493
  } else {
596
- // Normal stop — send the recorded audio/transcript to the iframe
597
494
  if (hpWhisperEnabled && hpMediaRecorder && hpMediaRecorder.state !== 'inactive') {
598
495
  var recorder = hpMediaRecorder;
599
496
  var stream = hpStream;
600
497
  recorder.onstop = function() {
601
498
  if (stream) stream.getTracks().forEach(function(t) { t.stop(); });
602
499
  var blob = new Blob(hpAudioChunks, { type: 'audio/webm' });
603
- hpAudioChunks = [];
604
- hpMediaRecorder = null;
605
- hpStream = null;
500
+ hpAudioChunks = []; hpMediaRecorder = null; hpStream = null;
606
501
  if (blob.size < 1000) return;
607
-
608
502
  var reader = new FileReader();
609
503
  reader.onloadend = function() {
610
- var dataUrl = reader.result;
611
- var base64 = dataUrl.split(',')[1];
612
- if (base64 && iframe.contentWindow) {
613
- iframe.contentWindow.postMessage({ type: 'bloby:voice-record', audio: base64 }, '*');
614
- }
504
+ var base64 = reader.result.split(',')[1];
505
+ if (base64 && iframe.contentWindow) iframe.contentWindow.postMessage({ type: 'bloby:voice-record', audio: base64 }, '*');
615
506
  };
616
507
  reader.readAsDataURL(blob);
617
508
  };
@@ -620,38 +511,25 @@
620
511
  var instance = hpSpeechInstance;
621
512
  var sent = false;
622
513
  var sendTranscript = function() {
623
- if (sent) return;
624
- sent = true;
514
+ if (sent) return; sent = true;
625
515
  var text = hpSpeechTranscript.trim();
626
516
  hpSpeechTranscript = '';
627
- if (text && iframe.contentWindow) {
628
- iframe.contentWindow.postMessage({ type: 'bloby:voice-record', transcript: text }, '*');
629
- }
630
- };
631
- instance.onend = function() {
632
- hpSpeechInstance = null;
633
- sendTranscript();
517
+ if (text && iframe.contentWindow) iframe.contentWindow.postMessage({ type: 'bloby:voice-record', transcript: text }, '*');
634
518
  };
635
- try { instance.stop(); } catch(e) {
636
- hpSpeechInstance = null;
637
- sendTranscript();
638
- }
639
- // Safety timeout
640
- setTimeout(function() {
641
- if (hpSpeechInstance === instance) hpSpeechInstance = null;
642
- sendTranscript();
643
- }, 2000);
519
+ instance.onend = function() { hpSpeechInstance = null; sendTranscript(); };
520
+ try { instance.stop(); } catch(e) { hpSpeechInstance = null; sendTranscript(); }
521
+ setTimeout(function() { if (hpSpeechInstance === instance) hpSpeechInstance = null; sendTranscript(); }, 2000);
644
522
  } else {
645
523
  if (hpStream) { hpStream.getTracks().forEach(function(t) { t.stop(); }); hpStream = null; }
646
524
  }
647
525
  }
648
526
 
649
- // Trigger deactivation animation
527
+ // v2 deactivation: reverse 40→11 (activate played backwards)
650
528
  if (hpState === 'activating') {
651
529
  hpState = 'activating_then_deactivate';
652
530
  } else if (hpState === 'recording') {
653
531
  hpState = 'deactivating';
654
- hpFrame = HP_DEACTIVATE_START;
532
+ hpFrame = HP_ACTIVATE_END;
655
533
  hpLastFrameTime = performance.now();
656
534
  }
657
535
  }
@@ -663,100 +541,71 @@
663
541
  function maybeTransition() {
664
542
  if (!appReady || animState !== 'idle' || canvasPhase !== 'splash') return;
665
543
  var elapsed = Date.now() - canvasCreatedAt;
666
- if (elapsed < MIN_SPLASH_MS) {
667
- setTimeout(maybeTransition, MIN_SPLASH_MS - elapsed);
668
- return;
669
- }
670
- triggerTransition();
671
- }
672
-
673
- function triggerTransition() {
544
+ if (elapsed < MIN_SPLASH_MS) { setTimeout(maybeTransition, MIN_SPLASH_MS - elapsed); return; }
674
545
  canvasPhase = 'transitioning';
675
546
  var tx = W - BUBBLE_MARGIN - BUBBLE_SIZE / 2;
676
547
  var ty = H - BUBBLE_MARGIN - BUBBLE_SIZE / 2;
677
548
  moveTo(tx, ty);
678
549
  }
679
550
 
680
- // Skip splash and go straight to bubble mode (onboard, soft refresh, etc.)
681
551
  function skipToBubble() {
682
552
  canvasPhase = 'bubble';
683
553
  canvas.style.cssText = BUBBLE_CSS;
684
- W = BUBBLE_SIZE;
685
- H = BUBBLE_SIZE;
554
+ W = BUBBLE_SIZE; H = BUBBLE_SIZE;
686
555
  canvas.width = BUBBLE_SIZE * dpr;
687
556
  canvas.height = BUBBLE_SIZE * dpr;
688
557
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
689
- center.x = BUBBLE_SIZE / 2;
690
- center.y = BUBBLE_SIZE / 2;
691
- currentFrame = IDLE_START;
692
- idleDirection = 1;
558
+ hpState = 'idle';
559
+ hpFrame = HP_IDLE_FRAME;
693
560
  animState = 'idle';
694
- // Hide the HTML splash fallback
695
561
  var splash = document.getElementById('splash');
696
562
  if (splash) splash.style.display = 'none';
697
- // Preload headphones sprite in background
698
563
  loadHpSprite();
699
564
  }
700
565
 
701
- window.addEventListener('bloby:app-ready', function () {
702
- appReady = true;
703
- maybeTransition();
704
- });
705
- if (window.__blobyAppReady) {
706
- appReady = true;
707
- }
566
+ window.addEventListener('bloby:app-ready', function () { appReady = true; maybeTransition(); });
567
+ if (window.__blobyAppReady) appReady = true;
708
568
 
709
- // ── Check onboard status + settings, then start ──
710
569
  var onboardActive = false;
711
570
  fetch('/api/settings')
712
571
  .then(function (r) { return r.json(); })
713
572
  .then(function (s) {
714
- if (s.onboard_complete !== 'true') {
715
- onboardActive = true;
716
- skipSplash = true;
717
- }
718
- if (s.whisper_enabled === 'true') {
719
- hpWhisperEnabled = true;
720
- }
721
- // Skip splash if already played this session
573
+ if (s.onboard_complete !== 'true') { onboardActive = true; skipSplash = true; }
574
+ if (s.whisper_enabled === 'true') hpWhisperEnabled = true;
722
575
  if (splashSeen) skipSplash = true;
723
576
  startAnimation();
724
577
  })
725
- .catch(function () {
726
- if (splashSeen) skipSplash = true;
727
- startAnimation();
728
- });
578
+ .catch(function () { if (splashSeen) skipSplash = true; startAnimation(); });
729
579
 
730
580
  function startAnimation() {
731
581
  loadSprite(
732
- function onSuccess() {
733
- if (skipSplash) {
734
- skipToBubble();
735
- if (onboardActive) canvas.style.display = 'none';
736
- }
582
+ function () {
583
+ if (skipSplash) { skipToBubble(); if (onboardActive) canvas.style.display = 'none'; }
737
584
  requestAnimationFrame(loop);
738
585
  maybeTransition();
739
586
  },
740
- function onFail() {
741
- canvas.style.display = 'none';
742
- canvasPhase = 'disabled';
743
- }
587
+ function () { canvas.style.display = 'none'; canvasPhase = 'disabled'; }
744
588
  );
745
589
  }
746
590
 
747
591
  // ══════════════════════════════════════════════════════════════════
748
- // ── Widget Interaction (Panel, Toggle, Bubble Pointer) ───────────
592
+ // ── Widget Interaction ───────────────────────────────────────────
749
593
  // ══════════════════════════════════════════════════════════════════
750
594
 
751
595
  var isOpen = false;
752
596
 
753
597
  function toggle() {
754
- // If clicked during splash or travel animation, skip straight to bubble first
755
598
  if (canvasPhase === 'splash' || canvasPhase === 'transitioning') {
756
599
  skipToBubble();
757
600
  try { sessionStorage.setItem(SPLASH_KEY, '1'); } catch(e) {}
758
601
  }
759
602
  if (canvasPhase !== 'bubble') return;
603
+ // If headphones animation is running, reset to idle
604
+ if (hpState !== 'idle') {
605
+ stopHpRecording(true);
606
+ hpState = 'idle';
607
+ hpFrame = HP_IDLE_FRAME;
608
+ }
760
609
  isOpen = !isOpen;
761
610
  panel.classList.toggle('open', isOpen);
762
611
  backdrop.classList.toggle('open', isOpen);
@@ -765,18 +614,13 @@
765
614
  updateBadge();
766
615
  }
767
616
 
768
- // ── Bubble pointer handlers (tap → open panel, long-press → headphones mic) ──
769
- // Uses setPointerCapture so finger can move slightly without losing the touch.
617
+ // ── Bubble pointer handlers ──
618
+ // Tap = open chat (always, any state). Hold ≥500ms = record.
770
619
  var hpPointerId = -1;
771
620
 
772
621
  bubble.addEventListener('pointerdown', function(e) {
622
+ if (canvasPhase !== 'bubble') return;
773
623
  e.preventDefault();
774
-
775
- // During headphones deactivation or activating, a tap opens chat
776
- if (canvasPhase === 'headphones') return;
777
-
778
- if (canvasPhase !== 'bubble' || hpState !== 'none') return;
779
-
780
624
  hpPointerId = e.pointerId;
781
625
  bubble.setPointerCapture(e.pointerId);
782
626
  hpPointerDown = true;
@@ -784,27 +628,22 @@
784
628
 
785
629
  hpHoldTimer = setTimeout(function() {
786
630
  hpWasHold = true;
787
- startHpRecording();
631
+ if (hpState === 'idle' || hpState === 'deactivating') {
632
+ startHpRecording();
633
+ }
788
634
  }, HP_HOLD_MS);
789
635
  });
790
636
 
791
- bubble.addEventListener('pointerup', function(e) {
637
+ bubble.addEventListener('pointerup', function() {
792
638
  hpPointerDown = false;
793
639
  if (hpHoldTimer) { clearTimeout(hpHoldTimer); hpHoldTimer = null; }
794
- if (hpPointerId !== -1) {
795
- try { bubble.releasePointerCapture(hpPointerId); } catch(ex) {}
796
- hpPointerId = -1;
797
- }
640
+ if (hpPointerId !== -1) { try { bubble.releasePointerCapture(hpPointerId); } catch(ex) {} hpPointerId = -1; }
798
641
 
799
642
  if (hpState === 'activating' || hpState === 'recording') {
643
+ // Release during recording → stop & deactivate
800
644
  stopHpRecording(false);
801
645
  } else if (!hpWasHold) {
802
- // Short tap open chat (works from bubble and during deactivation)
803
- if (canvasPhase === 'headphones') {
804
- // Skip deactivation animation, go straight to bubble, then open
805
- hpState = 'none';
806
- endHpCanvasMode();
807
- }
646
+ // Short tap open chat (works from any state)
808
647
  toggle();
809
648
  }
810
649
  });
@@ -812,83 +651,42 @@
812
651
  bubble.addEventListener('pointercancel', function() {
813
652
  hpPointerDown = false;
814
653
  if (hpHoldTimer) { clearTimeout(hpHoldTimer); hpHoldTimer = null; }
815
- if (hpPointerId !== -1) {
816
- try { bubble.releasePointerCapture(hpPointerId); } catch(ex) {}
817
- hpPointerId = -1;
818
- }
819
- if (hpState !== 'none' && hpState !== 'deactivating') stopHpRecording(true);
654
+ if (hpPointerId !== -1) { try { bubble.releasePointerCapture(hpPointerId); } catch(ex) {} hpPointerId = -1; }
655
+ if (hpState === 'activating' || hpState === 'recording') stopHpRecording(true);
820
656
  });
821
657
 
822
- // ── Click on blob during splash/travel → skip animation and open chat ──
658
+ // Click on blob during splash → skip and open
823
659
  document.addEventListener('click', function(e) {
824
660
  if (canvasPhase !== 'splash' && canvasPhase !== 'transitioning') return;
825
661
  if (animState === 'loading') return;
826
- var dx = e.clientX - center.x;
827
- var dy = e.clientY - center.y;
828
- if (Math.sqrt(dx * dx + dy * dy) < DISPLAY_H / 2 + 15) {
829
- toggle();
830
- }
831
- });
832
-
833
- // Prevent context menu on long-press (mobile)
834
- bubble.addEventListener('contextmenu', function(e) {
835
- e.preventDefault();
662
+ var dx = e.clientX - center.x, dy = e.clientY - center.y;
663
+ if (Math.sqrt(dx * dx + dy * dy) < DISPLAY_H / 2 + 15) toggle();
836
664
  });
837
665
 
666
+ bubble.addEventListener('contextmenu', function(e) { e.preventDefault(); });
838
667
  backdrop.addEventListener('click', toggle);
839
-
840
- // Close on Escape
841
- document.addEventListener('keydown', function (e) {
842
- if (e.key === 'Escape' && isOpen) toggle();
843
- });
668
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && isOpen) toggle(); });
844
669
 
845
670
  // ── PWA Install ──
846
671
  var deferredInstallPrompt = null;
847
- window.addEventListener('beforeinstallprompt', function (e) {
848
- e.preventDefault();
849
- deferredInstallPrompt = e;
850
- });
672
+ window.addEventListener('beforeinstallprompt', function (e) { e.preventDefault(); deferredInstallPrompt = e; });
851
673
 
852
674
  // Handle messages from iframe
853
675
  window.addEventListener('message', function (e) {
854
676
  if (!e.data || !e.data.type) return;
855
-
856
677
  if (e.data.type === 'bloby:close' && isOpen) toggle();
857
-
858
- // New message from agent — badge the bubble if panel is closed
859
- if (e.data.type === 'bloby:new-message' && !isOpen) {
860
- unreadCount++;
861
- updateBadge();
862
- }
863
-
678
+ if (e.data.type === 'bloby:new-message' && !isOpen) { unreadCount++; updateBadge(); }
864
679
  if (e.data.type === 'bloby:install-app') {
865
680
  if (deferredInstallPrompt) {
866
681
  deferredInstallPrompt.prompt();
867
- deferredInstallPrompt.userChoice.then(function (result) {
868
- if (result.outcome === 'accepted') deferredInstallPrompt = null;
869
- });
870
- } else {
871
- iframe.contentWindow.postMessage({ type: 'bloby:show-ios-install' }, '*');
872
- }
873
- }
874
-
875
- // Onboard complete — show the bubble
876
- if (e.data.type === 'bloby:onboard-complete') {
877
- onboardActive = false;
878
- hideAfterTransition = false;
879
- canvas.style.display = 'block';
682
+ deferredInstallPrompt.userChoice.then(function (r) { if (r.outcome === 'accepted') deferredInstallPrompt = null; });
683
+ } else { iframe.contentWindow.postMessage({ type: 'bloby:show-ios-install' }, '*'); }
880
684
  }
685
+ if (e.data.type === 'bloby:onboard-complete') { onboardActive = false; hideAfterTransition = false; canvas.style.display = 'block'; }
881
686
  });
882
687
 
883
- // Restore open state after HMR reload
884
- try {
885
- if (sessionStorage.getItem('bloby_widget_open') === '1') {
886
- sessionStorage.removeItem('bloby_widget_open');
887
- if (canvasPhase === 'bubble') toggle();
888
- }
889
- } catch (e) {}
688
+ try { if (sessionStorage.getItem('bloby_widget_open') === '1') { sessionStorage.removeItem('bloby_widget_open'); if (canvasPhase === 'bubble') toggle(); } } catch (e) {}
890
689
 
891
- // Inject app-ws.js — proxies /app/api/* fetch calls through WebSocket
892
690
  var awsScript = document.createElement('script');
893
691
  awsScript.src = '/bloby/app-ws.js';
894
692
  document.head.appendChild(awsScript);