@goodandready/dsh-tts 0.4.27 → 0.4.28

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.
package/lib/client.js CHANGED
@@ -21,10 +21,12 @@ window.__ModuleLoader__.load({
21
21
  const ROW_CONFIG_KEY = PKG + '#' + ROW_ID
22
22
  const PROVIDERS = [
23
23
  'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
24
- 'edge', 'piper', 'espeak',
24
+ 'siliconflow', 'deepinfra', 'fireworks', 'mimo', 'custom',
25
+ 'edge', 'piper', 'espeak', 'minimax',
25
26
  ]
26
27
  const CLOUD = {
27
- openai: 1, elevenlabs: 1, google: 1, azure: 1, groq: 1, deepgram: 1, openrouter: 1, custom: 1,
28
+ openai: 1, elevenlabs: 1, google: 1, azure: 1, groq: 1, deepgram: 1, openrouter: 1,
29
+ siliconflow: 1, deepinfra: 1, fireworks: 1, mimo: 1, custom: 1,
28
30
  }
29
31
  // Hints stay generic: concrete provider model IDs rot quickly.
30
32
  const MODEL_HINT = {
@@ -35,9 +37,15 @@ window.__ModuleLoader__.load({
35
37
  groq: 'provider default',
36
38
  deepgram: 'provider default (English-only voices)',
37
39
  openrouter: 'provider/model id',
38
- edge: 'BCP-47 voice, e.g. ru-RU-SvetlanaNeural',
40
+ siliconflow: 'model id (e.g. FunAudioLLM/CosyVoice2-0.5B)',
41
+ deepinfra: 'model id (e.g. hexgrad/Kokoro-82M)',
42
+ fireworks: 'model id (e.g. kokoro)',
43
+ mimo: 'model id (e.g. mimo-v2.5-tts)',
44
+ custom: 'custom model',
45
+ minimax: 'model id',
46
+ edge: 'BCP-47 voice, e.g. en-US-AriaNeural',
39
47
  piper: 'path to .onnx',
40
- espeak: 'language code, e.g. ru',
48
+ espeak: 'language code, e.g. en',
41
49
  kokoro: 'not bundled — offline neural runtime unavailable',
42
50
  f5: 'not bundled — offline neural runtime unavailable',
43
51
  }
@@ -76,7 +84,7 @@ window.__ModuleLoader__.load({
76
84
  style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
77
85
  onClick: () => this.setState({ hasError: false, error: null }),
78
86
  },
79
- 'Retry',
87
+ (typeof fallbackDockText === 'function' ? fallbackDockText('retry') : 'Retry'),
80
88
  ),
81
89
  )
82
90
  }
@@ -167,6 +175,8 @@ window.__ModuleLoader__.load({
167
175
  }
168
176
  } catch { /* no AudioContext */ }
169
177
  setAutoplayBlocked(false)
178
+ player.blocked = false
179
+ playerChanged()
170
180
  drainQueue()
171
181
  }
172
182
 
@@ -189,6 +199,7 @@ window.__ModuleLoader__.load({
189
199
  bargeIn: true,
190
200
  busy: false,
191
201
  paused: false,
202
+ blocked: false,
192
203
  listeners: new Set(),
193
204
  unlockAudio,
194
205
  useAutoplayGate,
@@ -293,6 +304,12 @@ window.__ModuleLoader__.load({
293
304
  if (doneCalled) return
294
305
  doneCalled = true
295
306
  currentAudioResolve = null
307
+ if (player.audio) {
308
+ try {
309
+ player.audio.pause()
310
+ player.audio.currentTime = 0
311
+ } catch { /* already paused */ }
312
+ }
296
313
  try { URL.revokeObjectURL(url) } catch { /* URL already revoked */ }
297
314
  item.prepared = null
298
315
  player.audio = null
@@ -303,9 +320,27 @@ window.__ModuleLoader__.load({
303
320
  audio.onended = done
304
321
  audio.onerror = done
305
322
  audio.play().catch((playErr) => {
306
- // Autoplay without a user gesture is blocked — surface unlock UX, then stop this chunk.
323
+ // Autoplay without a user gesture is blocked — retain queue and surface unlock UX (#160)
307
324
  if (playErr && (playErr.name === 'NotAllowedError' || playErr.name === 'SecurityError')) {
325
+ player.blocked = true
308
326
  setAutoplayBlocked(true)
327
+ player.queue.unshift(item)
328
+ currentAudioResolve = null
329
+ player.audio = null
330
+ player.busy = false
331
+ playerChanged()
332
+ if (typeof window !== 'undefined') {
333
+ const onUserGesture = () => {
334
+ window.removeEventListener('click', onUserGesture)
335
+ window.removeEventListener('keydown', onUserGesture)
336
+ window.removeEventListener('touchstart', onUserGesture)
337
+ unlockAudio()
338
+ }
339
+ window.addEventListener('click', onUserGesture, { once: true })
340
+ window.addEventListener('keydown', onUserGesture, { once: true })
341
+ window.addEventListener('touchstart', onUserGesture, { once: true })
342
+ }
343
+ return
309
344
  }
310
345
  done()
311
346
  })
@@ -317,12 +352,11 @@ window.__ModuleLoader__.load({
317
352
  }
318
353
 
319
354
  async function drainQueue() {
320
- if (player.busy) return
355
+ if (player.busy || player.blocked || player.paused) return
321
356
  player.busy = true
322
357
  playerChanged()
323
358
  try {
324
- while (player.queue.length) {
325
- if (player.paused) break
359
+ while (player.queue.length && !player.blocked && !player.paused) {
326
360
  const item = player.queue.shift()
327
361
  if (!item || item.kind === 'reserved') continue
328
362
  if (item.kind === 'chime') { await playChime(item.chime || player.chime); continue }
@@ -345,7 +379,10 @@ window.__ModuleLoader__.load({
345
379
  player.queue.length = 0
346
380
  player.paused = false
347
381
  if (player.audio) {
348
- try { player.audio.pause() } catch { /* already paused */ }
382
+ try {
383
+ player.audio.pause()
384
+ player.audio.currentTime = 0
385
+ } catch { /* already paused */ }
349
386
  }
350
387
  if (currentAudioResolve) {
351
388
  currentAudioResolve()
@@ -365,10 +402,15 @@ window.__ModuleLoader__.load({
365
402
  }
366
403
 
367
404
  function skipCurrent() {
405
+ if (player.audio) {
406
+ try {
407
+ player.audio.pause()
408
+ player.audio.currentTime = 0
409
+ } catch { /* ignore pause error */ }
410
+ }
368
411
  if (currentAudioResolve) {
369
412
  currentAudioResolve()
370
- } else if (player.audio) {
371
- try { player.audio.pause() } catch (already) { /* already stopped */ }
413
+ } else {
372
414
  player.audio = null
373
415
  playerChanged()
374
416
  if (player.queue.length) drainQueue()
@@ -392,70 +434,83 @@ window.__ModuleLoader__.load({
392
434
  return () => window.removeEventListener('dsh-voice:speaking', onSpeaking)
393
435
  }
394
436
 
395
- // SSE Realtime Stream Connection with Exponential Backoff & Jitter
396
- let sseAttempt = 0
397
- let sseTimer = null
398
- let sseSource = null
437
+ // Unified SSE Realtime Stream Connection (#158)
438
+ let activeSse = null
439
+ let activeSseAttempt = 0
440
+ let activeSseTimer = null
441
+ let sseDisposed = false
399
442
 
400
- function connectSseStream() {
401
- if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return
402
- if (sseSource) {
403
- try { sseSource.close() } catch (alreadyClosed) { /* intentional cleanup */ }
404
- sseSource = null
443
+ function startSseStream() {
444
+ if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return () => {}
445
+ sseDisposed = false
446
+
447
+ const onItem = (e) => {
448
+ try {
449
+ const item = JSON.parse(e.data)
450
+ if (!item || !item.id) return
451
+ if (player.enabled === false) return
452
+ player.after = item.id
453
+ if (seenIds.has(item.id)) return
454
+ seenIds.add(item.id)
455
+ if (seenIds.size > 500) {
456
+ const first = seenIds.values().next().value
457
+ seenIds.delete(first)
458
+ }
459
+ if (item.text) recent.add(item.text)
460
+ player.queue.push(item)
461
+ drainQueue()
462
+ } catch { /* ignore malformed SSE */ }
405
463
  }
406
- try {
407
- sseSource = new EventSource('/dsh-tts/stream')
408
- sseSource.onopen = () => {
409
- sseAttempt = 0
464
+
465
+ const onChime = (e) => {
466
+ try {
467
+ const item = JSON.parse(e.data)
468
+ if (!item) return
469
+ if (player.enabled === false) return
470
+ player.queue.push(item)
471
+ drainQueue()
472
+ } catch {}
473
+ }
474
+
475
+ const connect = () => {
476
+ if (sseDisposed) return
477
+ if (activeSse) {
478
+ try { activeSse.close() } catch {}
479
+ activeSse = null
410
480
  }
411
- sseSource.addEventListener('utterance', (e) => {
412
- sseAttempt = 0
413
- try {
414
- const item = JSON.parse(e.data)
415
- if (item && item.id) {
416
- player.after = item.id
417
- if (!seenIds.has(item.id)) {
418
- seenIds.add(item.id)
419
- if (seenIds.size > 500) {
420
- const first = seenIds.values().next().value
421
- seenIds.delete(first)
422
- }
423
- if (item.text) recent.add(item.text)
424
- player.queue.push(item)
425
- drainQueue()
426
- }
427
- }
428
- } catch (parseErr) { /* ignore malformed SSE payload */ }
429
- })
430
- sseSource.addEventListener('chime', (e) => {
431
- sseAttempt = 0
432
- try {
433
- const item = JSON.parse(e.data)
434
- if (item) {
435
- player.queue.push(item)
436
- drainQueue()
481
+ try {
482
+ const es = new EventSource('/dsh-tts/stream')
483
+ activeSse = es
484
+ es.onopen = () => { activeSseAttempt = 0 }
485
+ es.addEventListener('utterance', onItem)
486
+ es.addEventListener('chime', onChime)
487
+ es.onerror = () => {
488
+ if (activeSse === es) {
489
+ try { es.close() } catch {}
490
+ activeSse = null
437
491
  }
438
- } catch (parseErr) { /* ignore malformed chime payload */ }
439
- })
440
- sseSource.onerror = () => {
441
- if (sseSource) {
442
- try { sseSource.close() } catch (alreadyClosed) { /* intentional cleanup */ }
443
- sseSource = null
492
+ if (sseDisposed) return
493
+ activeSseAttempt++
494
+ const delay = Math.min(10000, 500 * Math.pow(2, Math.min(activeSseAttempt, 5)))
495
+ if (activeSseTimer) clearTimeout(activeSseTimer)
496
+ activeSseTimer = setTimeout(connect, delay)
444
497
  }
445
- sseAttempt++
446
- const baseDelay = Math.min(10000, 500 * Math.pow(2, sseAttempt - 1))
447
- const jitter = baseDelay * (Math.random() * 0.6 - 0.3)
448
- const delay = Math.max(200, Math.floor(baseDelay + jitter))
449
- if (sseTimer) clearTimeout(sseTimer)
450
- sseTimer = setTimeout(() => {
451
- connectSseStream()
452
- }, delay)
498
+ } catch { /* EventSource failed */ }
499
+ }
500
+
501
+ connect()
502
+
503
+ return () => {
504
+ sseDisposed = true
505
+ if (activeSseTimer) clearTimeout(activeSseTimer)
506
+ if (activeSse) {
507
+ try { activeSse.close() } catch {}
508
+ activeSse = null
453
509
  }
454
- } catch (esErr) { /* EventSource failed */ }
510
+ }
455
511
  }
456
512
 
457
- async function pollPending() {
458
- connectSseStream()
513
+ async function pollPending() {
459
514
  try {
460
515
  const st = await fetch('/dsh-tts/status', { cache: 'no-store' })
461
516
  if (!st.ok) return
@@ -463,7 +518,7 @@ async function pollPending() {
463
518
  player.enabled = !!(meta && meta.speakReplies)
464
519
  if (meta && typeof meta.rate === 'number') player.rate = meta.rate
465
520
  if (meta && meta.chime) player.chime = meta.chime
466
- if (meta && meta.roles) player.roles = meta.roles
521
+ if (meta && meta.roles) player.roles = meta.roles
467
522
  if (meta && typeof meta.bargeIn === 'boolean') player.bargeIn = meta.bargeIn
468
523
  if (!player.enabled) return
469
524
  const res = await fetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store' })
@@ -471,8 +526,6 @@ async function pollPending() {
471
526
  const data = await res.json()
472
527
  const items = data && Array.isArray(data.items) ? data.items : []
473
528
  for (const item of items) {
474
- // Queue slot reserved but synthesis still running — wait for the next poll
475
- // or phrase order would break.
476
529
  if (item.kind === 'reserved') break
477
530
  player.after = item.id
478
531
  if (seenIds.has(item.id)) continue
@@ -485,7 +538,7 @@ async function pollPending() {
485
538
  player.queue.push(item)
486
539
  }
487
540
  if (player.queue.length) drainQueue()
488
- } catch (hostRestarting) { /* host restarting */ }
541
+ } catch { /* host restarting */ }
489
542
  }
490
543
 
491
544
  // Recent utterances: last reply texts and favorites (localStorage).
@@ -514,8 +567,16 @@ async function pollPending() {
514
567
  }
515
568
  const recent = recentStore()
516
569
 
517
- // One-shot playback outside the queue: replay from recents.
570
+ // Unified playback through the shared player controller (#159)
571
+ function playAudioItem(item) {
572
+ stopPlayback()
573
+ player.queue.push(item)
574
+ drainQueue()
575
+ }
576
+
518
577
  function playStandalone(text) {
578
+ if (!text) return
579
+ stopPlayback()
519
580
  fetch('/dsh-tts/speak', {
520
581
  method: 'POST',
521
582
  headers: { 'Content-Type': 'application/json' },
@@ -523,13 +584,41 @@ async function pollPending() {
523
584
  })
524
585
  .then((r) => r.json())
525
586
  .then((d) => {
526
- if (!d || !d.ok) return
527
- const audio = new Audio('data:' + (d.mime || 'audio/mpeg') + ';base64,' + d.audioBase64)
528
- audio.play().catch(() => { /* chime autoplay blocked or interrupted */ })
587
+ if (!d || !d.ok || !d.audioBase64) return
588
+ playAudioItem({
589
+ id: `replay-${Date.now()}`,
590
+ text,
591
+ mime: d.mime || 'audio/mpeg',
592
+ audioBase64: d.audioBase64,
593
+ })
594
+ })
595
+ .catch(() => {})
596
+ }
597
+
598
+ function previewSpeech(provider, model, voice, text) {
599
+ stopPlayback()
600
+ return fetch('/dsh-tts/preview', {
601
+ method: 'POST',
602
+ headers: { 'Content-Type': 'application/json' },
603
+ body: JSON.stringify({ provider, model, voice, text }),
604
+ })
605
+ .then((r) => r.json())
606
+ .then((d) => {
607
+ if (!d || !d.ok) throw new Error((d && d.error && d.error.message) || 'HTTP error')
608
+ playAudioItem({
609
+ id: `preview-${Date.now()}`,
610
+ text,
611
+ mime: d.mime || 'audio/mpeg',
612
+ audioBase64: d.audioBase64,
613
+ })
529
614
  })
530
- .catch(() => { /* chime fetch failed or not found */ })
531
615
  }
532
616
 
617
+ player.playAudioItem = playAudioItem
618
+ player.previewSpeech = previewSpeech
619
+ player.startSseStream = startSseStream
620
+ player.skipCurrent = skipCurrent
621
+
533
622
  function exportAudioClip(text) {
534
623
  let phrase = text || player.lastText
535
624
  if (!phrase) {
@@ -565,7 +654,6 @@ async function pollPending() {
565
654
  function credOf(map, provider) {
566
655
  return (map && map[provider]) || { configured: false, writable: true, ref: '' }
567
656
  }
568
-
569
657
  const en = {
570
658
  'dockLabel': 'Speech',
571
659
  'dockPause': 'Pause',
@@ -760,6 +848,17 @@ async function pollPending() {
760
848
  'updaterUpdating': 'Updating…',
761
849
  'updaterSuccess': 'Plugin updated successfully! Please restart DSH service.',
762
850
  'updaterFailed': 'Update check failed: ',
851
+ 'exportJson': '📥 Export JSON',
852
+ 'importJson': '📤 Import JSON',
853
+ 'invalidJsonFormat': 'Invalid dictionary JSON format',
854
+ 'subtitle': 'Text-to-speech for DeepSeek Harness',
855
+ 'available': 'available',
856
+ 'dockSkip': 'Skip',
857
+ 'longReplyTruncate': 'Truncate',
858
+ 'longReplySummarize': 'Summarize',
859
+ 'longReplyFull': 'Full',
860
+ 'unlockAudio': 'Click to unlock audio',
861
+ 'errorLoading': 'Error loading settings: ',
763
862
  }
764
863
 
765
864
  const zh = {
@@ -959,6 +1058,14 @@ async function pollPending() {
959
1058
  'updaterUpdating': '正在更新…',
960
1059
  'updaterSuccess': '插件更新成功!请重启 DSH 服务以生效。',
961
1060
  'updaterFailed': '更新检查失败:',
1061
+ 'subtitle': 'DeepSeek Harness 语音合成插件',
1062
+ 'available': '可用',
1063
+ 'dockSkip': '跳过',
1064
+ 'longReplyTruncate': '截断',
1065
+ 'longReplySummarize': '总结',
1066
+ 'longReplyFull': '完整朗读',
1067
+ 'unlockAudio': '点击解锁音频',
1068
+ 'errorLoading': '加载设置失败:',
962
1069
  }
963
1070
  function ChainEditor(props) {
964
1071
  // Translator comes from the card: the slot passes props.t only to it.
@@ -1094,7 +1201,24 @@ async function pollPending() {
1094
1201
  )
1095
1202
  }
1096
1203
 
1097
- const CLIENT_IT_TERMS = []
1204
+ const CLIENT_IT_TERMS = [
1205
+ { from: 'API', to: 'A P I', whole: true, lang: '' },
1206
+ { from: 'CLI', to: 'C L I', whole: true, lang: '' },
1207
+ { from: 'CPU', to: 'C P U', whole: true, lang: '' },
1208
+ { from: 'GPU', to: 'G P U', whole: true, lang: '' },
1209
+ { from: 'RAM', to: 'ram', whole: true, lang: '' },
1210
+ { from: 'SQL', to: 'sequel', whole: true, lang: '' },
1211
+ { from: 'UI', to: 'U I', whole: true, lang: '' },
1212
+ { from: 'URL', to: 'U R L', whole: true, lang: '' },
1213
+ { from: 'UUID', to: 'U U I D', whole: true, lang: '' },
1214
+ { from: 'JSON', to: 'j-son', whole: true, lang: '' },
1215
+ { from: 'YAML', to: 'yam-el', whole: true, lang: '' },
1216
+ { from: 'HTML', to: 'H T M L', whole: true, lang: '' },
1217
+ { from: 'CSS', to: 'C S S', whole: true, lang: '' },
1218
+ { from: 'SSH', to: 'S S H', whole: true, lang: '' },
1219
+ { from: 'DNS', to: 'D N S', whole: true, lang: '' },
1220
+ { from: 'IP', to: 'I P', whole: true, lang: '' },
1221
+ ]
1098
1222
 
1099
1223
  // Pronunciation dictionary editor: rules apply top-down.
1100
1224
  function PronEditor(props) {
@@ -1426,7 +1550,7 @@ async function pollPending() {
1426
1550
  { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
1427
1551
  React.createElement('span', { className: 'dts-title' }, '🔄 ' + t('updaterTitle')),
1428
1552
  React.createElement('span', { className: 'dts-badge ' + (updateAvailable ? 'dts-badge-warn' : 'dts-badge-on') },
1429
- updateAvailable ? 'v' + latestVer + ' available' : 'v' + currentVer + ' (' + t('updaterUpToDate') + ')'
1553
+ updateAvailable ? 'v' + latestVer + ' ' + (t('available') || 'available') : 'v' + currentVer + ' (' + t('updaterUpToDate') + ')'
1430
1554
  )
1431
1555
  ),
1432
1556
  React.createElement(
@@ -1542,8 +1666,10 @@ async function pollPending() {
1542
1666
  // ready — values are present.
1543
1667
  // On unavailable, writable defaults true; without a status check the card
1544
1668
  // looks like a working empty form.
1545
- const ready = !scope || (!!snap && snap.status === 'ready')
1546
- const writable = !scope ? true : (ready && snap.writable !== false)
1669
+ // Snapshot status contract (#163):
1670
+ // Without valid scope, or when loading/unavailable, editing is disabled.
1671
+ const ready = !!scope && !!snap && snap.status === 'ready'
1672
+ const writable = ready && snap.writable !== false
1547
1673
 
1548
1674
  const applyPayload = (data) => {
1549
1675
  const cfg = data && data.config ? data.config : {}
@@ -1593,6 +1719,11 @@ async function pollPending() {
1593
1719
  return () => { alive = false; clearInterval(telTimer) }
1594
1720
  }, [])
1595
1721
 
1722
+ if (err && !draft) {
1723
+ return React.createElement('div', { className: 'dts-wrap dts-warn' },
1724
+ React.createElement('span', null, (t('errorLoading') || 'Error loading settings: ') + err),
1725
+ )
1726
+ }
1596
1727
  if (!draft) return React.createElement('div', { className: 'dts-wrap' }, t('loading'))
1597
1728
 
1598
1729
  const setTop = (key, v) => setDraft((d) => Object.assign({}, d || {}, { [key]: v }))
@@ -1668,15 +1799,7 @@ async function pollPending() {
1668
1799
  }
1669
1800
 
1670
1801
  const onPreview = (provider, model, voice, text) => {
1671
- fetch('/dsh-tts/preview', {
1672
- method: 'POST', headers: { 'Content-Type': 'application/json' },
1673
- body: JSON.stringify({ provider, model, voice, text }),
1674
- })
1675
- .then((r) => r.json())
1676
- .then((d) => {
1677
- if (!d.ok) throw new Error((d.error && d.error.message) || 'HTTP error')
1678
- new Audio('data:' + d.mime + ';base64,' + d.audioBase64).play().catch(() => { /* autoplay blocked */ })
1679
- })
1802
+ previewSpeech(provider, model, voice, text)
1680
1803
  .catch((e) => setErr(String(e && e.message ? e.message : e)))
1681
1804
  }
1682
1805
 
@@ -1686,9 +1809,9 @@ async function pollPending() {
1686
1809
  onPreview((first && first.provider) || 'kokoro', (first && first.model) || '', (first && first.voice) || '', phrase)
1687
1810
  }
1688
1811
 
1689
- const boolField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
1812
+ const boolField = (key, label, hint, disabled = false) => React.createElement('label', { className: 'dts-field' }, label,
1690
1813
  React.createElement('input', {
1691
- type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable,
1814
+ type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable || !!disabled,
1692
1815
  onChange: (e) => setTop(key, e.target.checked),
1693
1816
  }),
1694
1817
  React.createElement('span', { className: 'dts-sub' }, hint))
@@ -1827,7 +1950,7 @@ async function pollPending() {
1827
1950
  React.createElement('select', {
1828
1951
  className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
1829
1952
  onChange: (e) => setTop('longReply', e.target.value),
1830
- }, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
1953
+ }, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, t('longReply' + mode.charAt(0).toUpperCase() + mode.slice(1)) || mode))),
1831
1954
  React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
1832
1955
  textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
1833
1956
  numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
@@ -1929,13 +2052,17 @@ async function pollPending() {
1929
2052
  const [showList, setShowList] = React.useState(false)
1930
2053
  const active = !!p.audio || p.queue.length > 0 || p.busy
1931
2054
  const [, force] = React.useReducer((n) => n + 1, 0)
1932
- if (!active && !showList) return null
1933
2055
  const lists = recent.list()
2056
+ const hasHistory = (lists.items && lists.items.length > 0) || (lists.favs && lists.favs.length > 0)
2057
+
2058
+ // Hide only if completely idle and no recent/favorites history (#161)
2059
+ if (!active && !hasHistory && !showList && !p.blocked) return null
2060
+
1934
2061
  const row = (text, starred) => React.createElement('div', { className: 'dts-row', key: text.slice(0, 24) + starred },
1935
2062
  React.createElement('button', {
1936
2063
  type: 'button', className: 'dts-link', style: { flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
1937
2064
  title: text,
1938
- onClick: () => playStandalone(text), title: text,
2065
+ onClick: () => playStandalone(text),
1939
2066
  }, text.slice(0, 80)),
1940
2067
  React.createElement('button', {
1941
2068
  type: 'button', className: 'dts-link', title: '★',
@@ -1947,10 +2074,14 @@ async function pollPending() {
1947
2074
  }, '⤓'),
1948
2075
  )
1949
2076
  return React.createElement('div', { style: { position: 'relative', display: 'inline-flex', gap: '4px', alignItems: 'center' } },
1950
- React.createElement('button', {
2077
+ p.blocked ? React.createElement('button', {
2078
+ type: 'button', className: 'dts-link dts-warn', title: t('unlockAudio') || 'Click to unlock audio',
2079
+ onClick: unlockAudio,
2080
+ }, '🔔') : null,
2081
+ (hasHistory || active) ? React.createElement('button', {
1951
2082
  type: 'button', className: 'dts-link', title: t('recent') || 'Recent',
1952
2083
  onClick: () => setShowList((v) => !v),
1953
- }, '☰'),
2084
+ }, '☰') : null,
1954
2085
  showList ? React.createElement('div', {
1955
2086
  className: 'dts-wrap',
1956
2087
  style: { position: 'absolute', bottom: '36px', right: 0, zIndex: 30, maxHeight: '320px', overflowY: 'auto', background: 'var(--dsw-alias-bg-layer-2)', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '12px', padding: '10px 12px', minWidth: '260px', flexDirection: 'column', gap: '6px', display: 'flex' },
@@ -1961,24 +2092,32 @@ async function pollPending() {
1961
2092
  lists.items.filter((x) => !lists.favs.includes(x)).map((text) => row(text, false)),
1962
2093
  !lists.items.length ? React.createElement('span', { className: 'dts-sub' }, t('recentEmpty') || 'No recent speech yet') : null,
1963
2094
  ) : null,
1964
- React.createElement('button', {
1965
- type: 'button',
1966
- className: 'dts-link',
1967
- title: p.paused ? t('dockResume') : t('dockPause'),
1968
- onClick: togglePause,
1969
- }, p.paused ? '▶' : '❚❚'),
1970
- React.createElement('button', {
1971
- type: 'button',
1972
- className: 'dts-link',
1973
- title: t('dockStop'),
1974
- onClick: stopPlayback,
1975
- }, '■'),
1976
- React.createElement('button', {
1977
- type: 'button',
1978
- className: 'dts-link',
1979
- title: t('exportAudio') || 'Export clip',
1980
- onClick: () => exportAudioClip(player.lastText),
1981
- }, '⤓'),
2095
+ active ? React.createElement(React.Fragment, null,
2096
+ React.createElement('button', {
2097
+ type: 'button',
2098
+ className: 'dts-link',
2099
+ title: p.paused ? t('dockResume') : t('dockPause'),
2100
+ onClick: togglePause,
2101
+ }, p.paused ? '▶' : '❚❚'),
2102
+ React.createElement('button', {
2103
+ type: 'button',
2104
+ className: 'dts-link',
2105
+ title: t('dockStop'),
2106
+ onClick: stopPlayback,
2107
+ }, '■'),
2108
+ React.createElement('button', {
2109
+ type: 'button',
2110
+ className: 'dts-link',
2111
+ title: t('dockSkip') || 'Skip',
2112
+ onClick: skipCurrent,
2113
+ }, '⏭'),
2114
+ React.createElement('button', {
2115
+ type: 'button',
2116
+ className: 'dts-link',
2117
+ title: t('exportAudio') || 'Export clip',
2118
+ onClick: () => exportAudioClip(player.lastText),
2119
+ }, '⤓'),
2120
+ ) : null,
1982
2121
  )
1983
2122
  }
1984
2123
 
@@ -2071,7 +2210,7 @@ async function pollPending() {
2071
2210
  name: 'plugins.item',
2072
2211
  id: ROW_ID,
2073
2212
  order: 60,
2074
- label: () => 'Text to Speech',
2213
+ label: () => fallbackDockText('title') || 'Text to Speech',
2075
2214
  locale: NS,
2076
2215
  inject: () => ({ ctx: ctx }),
2077
2216
  },
@@ -2109,49 +2248,7 @@ async function pollPending() {
2109
2248
  window.addEventListener('keydown', onKey)
2110
2249
  return () => window.removeEventListener('keydown', onKey)
2111
2250
  }, 'dsh-tts: hotkeys')
2112
- ctx.effect(() => {
2113
- if (typeof window === 'undefined' || typeof EventSource === 'undefined') return () => {}
2114
- let es = null
2115
- let closed = false
2116
- let attempt = 0
2117
- let retryTimer = null
2118
- const onItem = (e) => {
2119
- try {
2120
- const item = JSON.parse(e.data)
2121
- if (!item || !item.id) return
2122
- if (seenIds.has(item.id)) return
2123
- seenIds.add(item.id)
2124
- if (seenIds.size > 500) {
2125
- const first = seenIds.values().next().value
2126
- seenIds.delete(first)
2127
- }
2128
- player.queue.push(item)
2129
- drainQueue()
2130
- } catch { /* ignore malformed SSE frame */ }
2131
- }
2132
- const connect = () => {
2133
- if (closed) return
2134
- try {
2135
- es = new EventSource('/dsh-tts/stream')
2136
- es.addEventListener('open', () => { attempt = 0 })
2137
- es.addEventListener('utterance', onItem)
2138
- es.addEventListener('chime', onItem)
2139
- es.onerror = () => {
2140
- try { es.close() } catch { /* already closed */ }
2141
- if (closed) return
2142
- attempt += 1
2143
- const delay = Math.min(15000, 500 * Math.pow(2, Math.min(attempt, 5)))
2144
- retryTimer = setTimeout(connect, delay)
2145
- }
2146
- } catch { /* EventSource constructor failed — poll remains */ }
2147
- }
2148
- connect()
2149
- return () => {
2150
- closed = true
2151
- if (retryTimer) clearTimeout(retryTimer)
2152
- if (es) { try { es.close() } catch { /* already closed */ } }
2153
- }
2154
- }, 'dsh-tts: SSE audio stream')
2251
+ ctx.effect(() => startSseStream(), 'dsh-tts: SSE audio stream')
2155
2252
  ctx.effect(() => {
2156
2253
  const timer = setInterval(pollPending, 1000)
2157
2254
  return () => clearInterval(timer)