@chat21/chat21-web-widget 5.1.34 → 5.2.1

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.
Files changed (191) hide show
  1. package/.angular-mcp-cache/package.json +1 -0
  2. package/.cursor/angular18-accessibility-auditor-skill.md +442 -0
  3. package/.cursor/mcp.json +15 -0
  4. package/.github/workflows/playwright.yml +27 -0
  5. package/CHANGELOG.md +25 -0
  6. package/Dockerfile +4 -5
  7. package/README.md +1 -1
  8. package/angular.json +21 -3
  9. package/docs/ACCESSIBILITY-STATEMENT.md +388 -0
  10. package/docs/TILEDESK_WIDGET_ACCESSIBILITY_ALIGNMENT.md +60 -0
  11. package/docs/TILEDESK_WIDGET_ACCESSIBILITY_STATEMENT_COMPLETE.md +386 -0
  12. package/env.sample +3 -2
  13. package/mocks/voice-websocket-mock/server.cjs +245 -0
  14. package/package.json +10 -3
  15. package/playwright.config.ts +41 -0
  16. package/src/app/app.component.html +2 -2
  17. package/src/app/app.component.scss +25 -14
  18. package/src/app/app.component.spec.ts +21 -6
  19. package/src/app/app.module.ts +13 -0
  20. package/src/app/component/conversation-detail/conversation/conversation.component.html +25 -11
  21. package/src/app/component/conversation-detail/conversation/conversation.component.scss +38 -0
  22. package/src/app/component/conversation-detail/conversation/conversation.component.spec.ts +644 -75
  23. package/src/app/component/conversation-detail/conversation/conversation.component.ts +70 -2
  24. package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.html +25 -13
  25. package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.spec.ts +123 -5
  26. package/src/app/component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component.ts +1 -0
  27. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.html +23 -10
  28. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.scss +18 -0
  29. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.spec.ts +241 -149
  30. package/src/app/component/conversation-detail/conversation-content/conversation-content.component.ts +8 -5
  31. package/src/app/component/conversation-detail/conversation-emojii/conversation-emojii.component.spec.ts +53 -3
  32. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.html +203 -110
  33. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.scss +212 -1
  34. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.spec.ts +458 -78
  35. package/src/app/component/conversation-detail/conversation-footer/conversation-footer.component.ts +288 -76
  36. package/src/app/component/conversation-detail/conversation-header/conversation-header.component.html +113 -53
  37. package/src/app/component/conversation-detail/conversation-header/conversation-header.component.scss +12 -4
  38. package/src/app/component/conversation-detail/conversation-header/conversation-header.component.spec.ts +274 -29
  39. package/src/app/component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component.html +23 -9
  40. package/src/app/component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component.spec.ts +80 -8
  41. package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.html +29 -23
  42. package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.spec.ts +185 -16
  43. package/src/app/component/conversation-detail/conversation-preview/conversation-preview.component.ts +34 -14
  44. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.html +46 -0
  45. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.scss +83 -0
  46. package/src/app/component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component.ts +192 -0
  47. package/src/app/component/error-alert/error-alert.component.spec.ts +65 -5
  48. package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.html +16 -7
  49. package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.scss +21 -0
  50. package/src/app/component/eyeeye-catcher-card/eyeeye-catcher-card.component.spec.ts +89 -7
  51. package/src/app/component/form/form-builder/form-builder.component.html +1 -1
  52. package/src/app/component/form/form-builder/form-builder.component.spec.ts +163 -21
  53. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.html +8 -4
  54. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.scss +10 -5
  55. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.spec.ts +90 -16
  56. package/src/app/component/form/inputs/form-checkbox/form-checkbox.component.ts +26 -0
  57. package/src/app/component/form/inputs/form-label/form-label.component.spec.ts +45 -11
  58. package/src/app/component/form/inputs/form-radio-button/form-radio-button.component.spec.ts +24 -6
  59. package/src/app/component/form/inputs/form-select/form-select.component.spec.ts +14 -5
  60. package/src/app/component/form/inputs/form-text/form-text.component.html +14 -12
  61. package/src/app/component/form/inputs/form-text/form-text.component.scss +11 -1
  62. package/src/app/component/form/inputs/form-text/form-text.component.spec.ts +113 -17
  63. package/src/app/component/form/inputs/form-text/form-text.component.ts +35 -3
  64. package/src/app/component/form/inputs/form-textarea/form-textarea.component.html +13 -11
  65. package/src/app/component/form/inputs/form-textarea/form-textarea.component.scss +6 -5
  66. package/src/app/component/form/inputs/form-textarea/form-textarea.component.spec.ts +149 -13
  67. package/src/app/component/form/inputs/form-textarea/form-textarea.component.ts +26 -0
  68. package/src/app/component/form/prechat-form/prechat-form.component.html +14 -11
  69. package/src/app/component/form/prechat-form/prechat-form.component.spec.ts +102 -10
  70. package/src/app/component/form/prechat-form/prechat-form.component.ts +8 -1
  71. package/src/app/component/form/prechat-form-test-mock.ts +35 -0
  72. package/src/app/component/home/home.component.html +38 -31
  73. package/src/app/component/home/home.component.scss +4 -2
  74. package/src/app/component/home/home.component.spec.ts +226 -11
  75. package/src/app/component/home-conversations/home-conversations.component.html +30 -26
  76. package/src/app/component/home-conversations/home-conversations.component.scss +3 -0
  77. package/src/app/component/home-conversations/home-conversations.component.spec.ts +212 -36
  78. package/src/app/component/last-message/last-message.component.html +15 -9
  79. package/src/app/component/last-message/last-message.component.scss +16 -2
  80. package/src/app/component/last-message/last-message.component.spec.ts +204 -23
  81. package/src/app/component/launcher-button/launcher-button.component.html +8 -13
  82. package/src/app/component/launcher-button/launcher-button.component.spec.ts +104 -8
  83. package/src/app/component/list-all-conversations/list-all-conversations.component.html +12 -17
  84. package/src/app/component/list-all-conversations/list-all-conversations.component.scss +2 -0
  85. package/src/app/component/list-conversations/list-conversations.component.html +22 -22
  86. package/src/app/component/menu-options/menu-options.component.html +30 -20
  87. package/src/app/component/menu-options/menu-options.component.spec.ts +125 -9
  88. package/src/app/component/message/audio/audio.component.html +13 -15
  89. package/src/app/component/message/audio/audio.component.spec.ts +140 -5
  90. package/src/app/component/message/audio/audio.component.ts +1 -5
  91. package/src/app/component/message/audio-sync/audio-sync.component.html +18 -0
  92. package/src/app/component/message/audio-sync/audio-sync.component.scss +65 -0
  93. package/src/app/component/message/audio-sync/audio-sync.component.spec.ts +112 -0
  94. package/src/app/component/message/audio-sync/audio-sync.component.ts +714 -0
  95. package/src/app/component/message/avatar/avatar.component.html +2 -2
  96. package/src/app/component/message/avatar/avatar.component.spec.ts +99 -7
  97. package/src/app/component/message/bubble-message/bubble-message.component.html +41 -51
  98. package/src/app/component/message/bubble-message/bubble-message.component.scss +54 -1
  99. package/src/app/component/message/bubble-message/bubble-message.component.spec.ts +147 -57
  100. package/src/app/component/message/bubble-message/bubble-message.component.ts +95 -13
  101. package/src/app/component/message/buttons/action-button/action-button.component.html +3 -4
  102. package/src/app/component/message/buttons/action-button/action-button.component.spec.ts +49 -5
  103. package/src/app/component/message/buttons/link-button/link-button.component.scss +5 -8
  104. package/src/app/component/message/buttons/link-button/link-button.component.spec.ts +50 -5
  105. package/src/app/component/message/buttons/text-button/text-button.component.spec.ts +44 -5
  106. package/src/app/component/message/carousel/carousel.component.html +29 -16
  107. package/src/app/component/message/carousel/carousel.component.scss +20 -8
  108. package/src/app/component/message/carousel/carousel.component.spec.ts +80 -3
  109. package/src/app/component/message/carousel/carousel.component.ts +16 -0
  110. package/src/app/component/message/frame/frame.component.html +9 -4
  111. package/src/app/component/message/frame/frame.component.spec.ts +34 -15
  112. package/src/app/component/message/frame/frame.component.ts +7 -2
  113. package/src/app/component/message/html/html.component.html +1 -1
  114. package/src/app/component/message/html/html.component.scss +1 -1
  115. package/src/app/component/message/html/html.component.spec.ts +24 -7
  116. package/src/app/component/message/image/image.component.html +12 -10
  117. package/src/app/component/message/image/image.component.scss +16 -0
  118. package/src/app/component/message/image/image.component.spec.ts +101 -15
  119. package/src/app/component/message/image/image.component.ts +90 -51
  120. package/src/app/component/message/info-message/info-message.component.spec.ts +26 -14
  121. package/src/app/component/message/json-sources/json-sources.component.html +6 -5
  122. package/src/app/component/message/json-sources/json-sources.component.scss +26 -18
  123. package/src/app/component/message/json-sources/json-sources.component.ts +41 -0
  124. package/src/app/component/message/like-unlike/like-unlike.component.html +7 -9
  125. package/src/app/component/message/like-unlike/like-unlike.component.spec.ts +31 -3
  126. package/src/app/component/message/return-receipt/return-receipt.component.spec.ts +38 -17
  127. package/src/app/component/message/text/text.component.html +3 -3
  128. package/src/app/component/message/text/text.component.scss +80 -86
  129. package/src/app/component/message/text/text.component.spec.ts +106 -13
  130. package/src/app/component/message-attachment/message-attachment.component.spec.ts +134 -13
  131. package/src/app/component/selection-department/selection-department.component.html +21 -23
  132. package/src/app/component/selection-department/selection-department.component.spec.ts +159 -14
  133. package/src/app/component/selection-department/selection-department.component.ts +8 -1
  134. package/src/app/component/send-button/send-button.component.html +5 -13
  135. package/src/app/component/send-button/send-button.component.spec.ts +2 -2
  136. package/src/app/component/star-rating-widget/star-rating-widget.component.html +51 -81
  137. package/src/app/directives/tooltip.directive.spec.ts +8 -4
  138. package/src/app/modals/confirm-close/confirm-close.component.html +20 -8
  139. package/src/app/modals/confirm-close/confirm-close.component.scss +3 -0
  140. package/src/app/modals/confirm-close/confirm-close.component.spec.ts +13 -4
  141. package/src/app/modals/confirm-close/confirm-close.component.ts +8 -1
  142. package/src/app/pipe/html-entites-encode.pipe.spec.ts +35 -2
  143. package/src/app/pipe/marked.pipe.spec.ts +38 -2
  144. package/src/app/pipe/marked.pipe.ts +51 -41
  145. package/src/app/providers/app-config.service.ts +4 -2
  146. package/src/app/providers/brand.service.spec.ts +23 -2
  147. package/src/app/providers/brand.service.ts +1 -1
  148. package/src/app/providers/global-settings.service.spec.ts +1009 -14
  149. package/src/app/providers/global-settings.service.ts +40 -2
  150. package/src/app/providers/json-sources-parser.service.ts +13 -1
  151. package/src/app/providers/translator.service.ts +24 -7
  152. package/src/app/providers/tts-audio-playback-coordinator.service.spec.ts +116 -0
  153. package/src/app/providers/tts-audio-playback-coordinator.service.ts +122 -0
  154. package/src/app/providers/voice/STT&TTS/openai-voice.config.ts +12 -0
  155. package/src/app/providers/voice/STT&TTS/openai-voice.provider.ts +156 -0
  156. package/src/app/providers/voice/STT&TTS/speech-provider.abstract.ts +39 -0
  157. package/src/app/providers/voice/audio.types.ts +40 -0
  158. package/src/app/providers/voice/vad.service.spec.ts +28 -0
  159. package/src/app/providers/voice/vad.service.ts +70 -0
  160. package/src/app/providers/voice/voice-streaming.service.spec.ts +23 -0
  161. package/src/app/providers/voice/voice-streaming.service.ts +702 -0
  162. package/src/app/providers/voice/voice-streaming.types.ts +112 -0
  163. package/src/app/providers/voice/voice.service.spec.ts +227 -0
  164. package/src/app/providers/voice/voice.service.ts +969 -0
  165. package/src/app/sass/_variables.scss +2 -0
  166. package/src/app/sass/animations.scss +19 -1
  167. package/src/app/shims/onnxruntime-web-wasm.ts +4 -0
  168. package/src/app/utils/globals.ts +14 -0
  169. package/src/app/utils/utils-resources.ts +1 -1
  170. package/src/assets/i18n/en.json +128 -100
  171. package/src/assets/i18n/es.json +128 -100
  172. package/src/assets/i18n/fr.json +128 -100
  173. package/src/assets/i18n/it.json +128 -98
  174. package/src/assets/onnx/ort-wasm-simd-threaded.mjs +59 -0
  175. package/src/assets/onnx/ort-wasm-simd-threaded.wasm +0 -0
  176. package/src/assets/sounds/keyboard.mp3 +0 -0
  177. package/src/assets/vad/silero_vad_legacy.onnx +0 -0
  178. package/src/assets/vad/vad.worklet.bundle.min.js +1 -0
  179. package/src/chat21-core/models/message.ts +2 -1
  180. package/src/chat21-core/providers/chat-manager.spec.ts +72 -0
  181. package/src/chat21-core/providers/firebase/firebase-conversation-handler.ts +3 -2
  182. package/src/chat21-core/providers/firebase/firebase-init-service.ts +5 -5
  183. package/src/chat21-core/providers/mqtt/mqtt-conversation-handler.ts +12 -0
  184. package/src/chat21-core/providers/scripts/script.service.spec.ts +12 -2
  185. package/src/chat21-core/utils/utils-message.ts +7 -0
  186. package/src/widget-config-template.json +3 -1
  187. package/src/widget-config.json +28 -27
  188. package/tests/widget-form-rich.spec.ts +67 -0
  189. package/tests/widget-index-dev-settings.spec.ts +52 -0
  190. package/tests/widget-twp-iframe.spec.ts +39 -0
  191. package/tsconfig.json +5 -0
@@ -0,0 +1,714 @@
1
+ import {
2
+ AfterViewInit,
3
+ ChangeDetectorRef,
4
+ Component,
5
+ ElementRef,
6
+ Input,
7
+ OnChanges,
8
+ OnDestroy,
9
+ SimpleChanges,
10
+ ViewChild,
11
+ } from '@angular/core';
12
+ import { Subscription } from 'rxjs';
13
+ import { MessageModel } from 'src/chat21-core/models/message';
14
+ import { TtsAudioPlaybackCoordinator } from 'src/app/providers/tts-audio-playback-coordinator.service';
15
+ import { VoiceService } from 'src/app/providers/voice/voice.service';
16
+ import { Globals } from 'src/app/utils/globals';
17
+
18
+ /** HAVE_METADATA: metadati già disponibili (tipico audio servito da cache). */
19
+ const HAVE_METADATA = 1;
20
+ const BROWSER_TTS_OUTPUT_FORMAT = 'mp3_44100_128';
21
+
22
+ @Component({
23
+ selector: 'chat-audio-sync',
24
+ templateUrl: './audio-sync.component.html',
25
+ styleUrl: './audio-sync.component.scss',
26
+ })
27
+ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
28
+ @Input() message: MessageModel | null = null;
29
+ @Input() color?: string;
30
+
31
+ @ViewChild('audioPlayer') audioRef!: ElementRef<HTMLAudioElement>;
32
+ @ViewChild('transcriptBox') transcriptBox!: ElementRef<HTMLElement>;
33
+
34
+ words: {
35
+ text: string;
36
+ start: number;
37
+ end: number;
38
+ state: 'future' | 'active' | 'past';
39
+ }[] = [];
40
+
41
+ currentTime = 0;
42
+ duration = 1;
43
+ activeIndex = -1;
44
+
45
+ private timingReady = false;
46
+ private onMetadataLoaded: () => void;
47
+ private onPlaybackEnded: () => void;
48
+
49
+ /** Id univoco per il coordinatore (di solito `message.uid`). */
50
+ private playbackOwnerId = '';
51
+ private destroyed = false;
52
+ private playbackRequested = false;
53
+ private playbackStarted = false;
54
+ private micInterrupted = false;
55
+ private streamAbort?: AbortController;
56
+ private mediaSourceObjectUrl?: string;
57
+ private cancelAllSub?: Subscription;
58
+ private micSpeechSub?: Subscription;
59
+ private stopAllSub?: Subscription;
60
+ private preemptSub?: Subscription;
61
+
62
+ constructor(
63
+ private readonly cdr: ChangeDetectorRef,
64
+ private readonly ttsPlayback: TtsAudioPlaybackCoordinator,
65
+ private readonly globals: Globals,
66
+ private readonly voiceService: VoiceService,
67
+ ) {}
68
+
69
+ /** `false` = messaggio già in storico: niente autoplay / karaoke. Da `message.isJustRecived`. */
70
+ private get skipSyncAnimation(): boolean {
71
+ return this.message?.isJustRecived === false;
72
+ }
73
+
74
+ ngOnChanges(changes: SimpleChanges): void {
75
+ if (!changes['message']) {
76
+ return;
77
+ }
78
+ if (this.audioRef?.nativeElement && this.timingReady) {
79
+ const d = this.audioRef.nativeElement.duration;
80
+ if (Number.isFinite(d) && d > 0) {
81
+ this.duration = d;
82
+ }
83
+ this.buildFakeTiming();
84
+ if (this.skipSyncAnimation) {
85
+ this.markAllWordsPast();
86
+ } else if (this.playbackStarted) {
87
+ this.syncStatesFromCurrentTime();
88
+ }
89
+ }
90
+ }
91
+
92
+ ngAfterViewInit(): void {
93
+ const audio = this.audioRef.nativeElement;
94
+
95
+ this.playbackOwnerId =
96
+ (this.message?.uid && String(this.message.uid).trim()) ||
97
+ `tts-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
98
+
99
+ // Se l’utente parla al microfono mentre sta ascoltando, interrompi TUTTO (corrente + coda).
100
+ this.micSpeechSub = this.voiceService.speechStart$.subscribe(() => {
101
+ if (this.destroyed) {
102
+ return;
103
+ }
104
+ // interrompi solo se questo messaggio era in riproduzione o in attesa
105
+ if (this.playbackStarted || this.playbackRequested) {
106
+ this.micInterrupted = true;
107
+ this.ttsPlayback.cancelAll();
108
+ this.interruptPlaybackAndRevealText();
109
+ }
110
+ });
111
+
112
+ // Stop globale (es. mic) notificato dal coordinatore: ogni istanza deve fermarsi e mostrare testo intero.
113
+ this.cancelAllSub = this.ttsPlayback.cancelAll$.subscribe(() => {
114
+ if (this.destroyed) {
115
+ return;
116
+ }
117
+ this.micInterrupted = true;
118
+ this.interruptPlaybackAndRevealText();
119
+ });
120
+
121
+ this.onPlaybackEnded = () => {
122
+ this.playbackStarted = false;
123
+ this.cleanupStreaming();
124
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
125
+ if (this.skipSyncAnimation) {
126
+ return;
127
+ }
128
+ this.markAllWordsPast();
129
+ if (this.message) {
130
+ this.message.isJustRecived = false;
131
+ }
132
+ this.cdr.detectChanges();
133
+ };
134
+
135
+ this.onMetadataLoaded = () => {
136
+ // La durata potrebbe arrivare tardi (specie con streaming).
137
+ const d = audio.duration;
138
+ if (Number.isFinite(d) && d > 0) {
139
+ this.duration = d;
140
+ } else if (!this.timingReady) {
141
+ this.duration = this.estimateDurationSecondsFromText();
142
+ }
143
+
144
+ this.timingReady = true;
145
+ this.buildFakeTiming();
146
+ if (this.skipSyncAnimation) {
147
+ this.markAllWordsPast();
148
+ this.cdr.detectChanges();
149
+ return;
150
+ }
151
+ if (this.playbackStarted) {
152
+ this.syncStatesFromCurrentTime();
153
+ }
154
+ this.cdr.detectChanges();
155
+ };
156
+
157
+ audio.addEventListener('loadedmetadata', this.onMetadataLoaded);
158
+ audio.addEventListener('ended', this.onPlaybackEnded);
159
+
160
+ // Prepara subito le parole (durata stimata) e poi aggiorna quando arriva la metadata reale.
161
+ this.duration = this.estimateDurationSecondsFromText();
162
+ this.timingReady = true;
163
+ this.buildFakeTiming();
164
+ if (this.skipSyncAnimation) {
165
+ this.markAllWordsPast();
166
+ this.cdr.detectChanges();
167
+ return;
168
+ }
169
+ this.cdr.detectChanges();
170
+
171
+ setTimeout(() => {
172
+ if (this.playbackRequested || this.destroyed || this.micInterrupted) {
173
+ if (this.micInterrupted) {
174
+ this.markAllWordsPast();
175
+ if (this.message) {
176
+ this.message.isJustRecived = false;
177
+ }
178
+ this.cdr.detectChanges();
179
+ }
180
+ return;
181
+ }
182
+ this.playbackRequested = true;
183
+ this.ttsPlayback.requestStart(this.playbackOwnerId, () => {
184
+ if (this.destroyed || this.micInterrupted) {
185
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
186
+ return;
187
+ }
188
+ this.playbackStarted = true;
189
+ this.syncStatesFromCurrentTime();
190
+ this.cdr.detectChanges();
191
+ this.startPlayback(audio);
192
+ });
193
+ }, 200);
194
+
195
+ // Stop signal: user pressed X while this TTS was playing or queued.
196
+ this.stopAllSub = this.ttsPlayback.stopAllPlayback$.subscribe(() => {
197
+ if (!this.playbackRequested && !this.playbackStarted) {
198
+ return;
199
+ }
200
+ this.destroyed = true;
201
+ this.playbackStarted = false;
202
+ this.cleanupStreaming();
203
+ try {
204
+ audio.pause();
205
+ audio.currentTime = 0;
206
+ } catch {
207
+ /* ignore */
208
+ }
209
+ this.markAllWordsPast();
210
+ if (this.message) {
211
+ this.message.isJustRecived = false;
212
+ }
213
+ this.cdr.detectChanges();
214
+ });
215
+
216
+ // Preempt signal: a newer message requested start while this one was playing.
217
+ // Only react when the emitted id matches this component's own ownerId.
218
+ this.preemptSub = this.ttsPlayback.preemptPlayback$.subscribe((stoppedId) => {
219
+ if (stoppedId !== this.playbackOwnerId) {
220
+ return;
221
+ }
222
+ this.playbackStarted = false;
223
+ this.cleanupStreaming();
224
+ try {
225
+ audio.pause();
226
+ audio.currentTime = 0;
227
+ } catch {
228
+ /* ignore */
229
+ }
230
+ this.markAllWordsPast();
231
+ if (this.message) {
232
+ this.message.isJustRecived = false;
233
+ }
234
+ this.cdr.detectChanges();
235
+ // No releaseIfCurrent call — the coordinator already cleared currentOwnerId before emitting.
236
+ });
237
+ }
238
+
239
+ ngOnDestroy(): void {
240
+ this.destroyed = true;
241
+ this.playbackStarted = false;
242
+ this.cleanupStreaming();
243
+ this.cancelAllSub?.unsubscribe();
244
+ this.micSpeechSub?.unsubscribe();
245
+ this.stopAllSub?.unsubscribe();
246
+ this.stopAllSub = undefined;
247
+ this.preemptSub?.unsubscribe();
248
+ this.preemptSub = undefined;
249
+
250
+ const audio = this.audioRef?.nativeElement;
251
+ if (audio) {
252
+ try {
253
+ audio.pause();
254
+ audio.currentTime = 0;
255
+ } catch {
256
+ /* ignore */
257
+ }
258
+ }
259
+ this.ttsPlayback.release(this.playbackOwnerId);
260
+
261
+ if (!audio) {
262
+ return;
263
+ }
264
+ if (this.onMetadataLoaded) {
265
+ audio.removeEventListener('loadedmetadata', this.onMetadataLoaded);
266
+ }
267
+ if (this.onPlaybackEnded) {
268
+ audio.removeEventListener('ended', this.onPlaybackEnded);
269
+ }
270
+ }
271
+
272
+ private interruptPlaybackAndRevealText(): void {
273
+ this.playbackStarted = false;
274
+ this.cleanupStreaming();
275
+
276
+ const audio = this.audioRef?.nativeElement;
277
+ if (audio) {
278
+ try {
279
+ audio.pause();
280
+ audio.currentTime = 0;
281
+ } catch {
282
+ /* ignore */
283
+ }
284
+ }
285
+
286
+ // Rimuove se era in coda (o rilascia se era corrente).
287
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
288
+
289
+ // Mostra tutto il testo (niente "future" invisibili).
290
+ this.markAllWordsPast();
291
+ if (this.message) {
292
+ this.message.isJustRecived = false;
293
+ }
294
+ this.cdr.detectChanges();
295
+ }
296
+
297
+ private startPlayback(audio: HTMLAudioElement): void {
298
+ const messageSrc = (this.message as any)?.metadata?.src as string | undefined;
299
+
300
+ if (this.message?.type === 'tts') {
301
+ const streamEndpoint = this.voiceService.proxyTtsStreamUrl;
302
+ const fullFileEndpoint = this.voiceService.proxyTtsUrl;
303
+ if (streamEndpoint) {
304
+ this.startStreamingFromEndpoint(audio, streamEndpoint, fullFileEndpoint, messageSrc);
305
+ return;
306
+ }
307
+ if (fullFileEndpoint) {
308
+ this.fetchFullFileFromEndpoint(audio, fullFileEndpoint);
309
+ return;
310
+ }
311
+ if (messageSrc) {
312
+ this.playDirectUrl(audio, messageSrc);
313
+ return;
314
+ }
315
+ this.handlePlaybackError();
316
+ return;
317
+ }
318
+
319
+ if (!messageSrc) {
320
+ this.playbackStarted = false;
321
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
322
+ this.markAllWordsPast();
323
+ if (this.message) {
324
+ this.message.isJustRecived = false;
325
+ }
326
+ this.cdr.detectChanges();
327
+ return;
328
+ }
329
+
330
+ this.playDirectUrl(audio, messageSrc);
331
+ }
332
+
333
+ private playDirectUrl(audio: HTMLAudioElement, src: string): void {
334
+ audio.src = src;
335
+ try {
336
+ audio.currentTime = 0;
337
+ } catch {
338
+ /* ignore */
339
+ }
340
+ audio.play().catch(() => this.handlePlaybackError());
341
+ }
342
+
343
+ private startStreamingFromEndpoint(
344
+ audio: HTMLAudioElement,
345
+ endpoint: string,
346
+ fullFileEndpoint?: string | null,
347
+ directFallbackSrc?: string,
348
+ ): void {
349
+ this.cleanupStreaming();
350
+
351
+ const jwt = this.getJwtToken();
352
+ const voiceSettings = this.getVoiceSettingsBody();
353
+ const requestBody = this.buildTtsRequestBody(voiceSettings);
354
+ let fallbackUsed = false;
355
+ const fallback = () => {
356
+ if (fallbackUsed) {
357
+ this.handlePlaybackError();
358
+ return;
359
+ }
360
+ fallbackUsed = true;
361
+ this.cleanupStreaming();
362
+ if (fullFileEndpoint) {
363
+ this.fetchFullFileFromEndpoint(audio, fullFileEndpoint);
364
+ return;
365
+ }
366
+ if (directFallbackSrc) {
367
+ this.playDirectUrl(audio, directFallbackSrc);
368
+ return;
369
+ }
370
+ this.handlePlaybackError();
371
+ };
372
+
373
+ // <audio src="..."> non può inviare header/body: serve fetch().
374
+ const hasMse = typeof (window as any).MediaSource !== 'undefined';
375
+ if (!hasMse) {
376
+ fallback();
377
+ return;
378
+ }
379
+
380
+ const MediaSourceCtor = (window as any).MediaSource as typeof MediaSource;
381
+ const mediaSource = new MediaSourceCtor();
382
+ const objectUrl = URL.createObjectURL(mediaSource);
383
+ this.mediaSourceObjectUrl = objectUrl;
384
+ audio.src = objectUrl;
385
+
386
+ const abort = new AbortController();
387
+ this.streamAbort = abort;
388
+
389
+ const onSourceOpen = async () => {
390
+ mediaSource.removeEventListener('sourceopen', onSourceOpen);
391
+ try {
392
+ const headers: Record<string, string> = {
393
+ 'Content-Type': 'application/json',
394
+ };
395
+ if (jwt) {
396
+ headers['Authorization'] = jwt;
397
+ }
398
+
399
+ const response = await fetch(endpoint, {
400
+ method: 'POST',
401
+ headers,
402
+ body: JSON.stringify(requestBody),
403
+ signal: abort.signal,
404
+ });
405
+ if (!response.ok || !response.body) {
406
+ throw new Error(`TTS stream request failed (${response.status})`);
407
+ }
408
+
409
+ const headerType = (response.headers.get('content-type') || '').split(';')[0].trim();
410
+ if (headerType && !MediaSourceCtor.isTypeSupported(headerType)) {
411
+ // Fallback: fetch completo e play via blob (no streaming).
412
+ fallback();
413
+ return;
414
+ }
415
+
416
+ const mime = headerType || 'audio/mpeg';
417
+ if (!MediaSourceCtor.isTypeSupported(mime)) {
418
+ fallback();
419
+ return;
420
+ }
421
+
422
+ const sourceBuffer = mediaSource.addSourceBuffer(mime);
423
+ sourceBuffer.mode = 'sequence';
424
+
425
+ const reader = response.body.getReader();
426
+ const queue: Uint8Array[] = [];
427
+ let doneReading = false;
428
+ let started = false;
429
+
430
+ const tryEndOfStream = () => {
431
+ if (doneReading && queue.length === 0 && !sourceBuffer.updating) {
432
+ try {
433
+ mediaSource.endOfStream();
434
+ } catch {
435
+ /* ignore */
436
+ }
437
+ }
438
+ };
439
+
440
+ const pump = () => {
441
+ if (abort.signal.aborted) {
442
+ return;
443
+ }
444
+ if (sourceBuffer.updating) {
445
+ return;
446
+ }
447
+ const chunk = queue.shift();
448
+ if (!chunk) {
449
+ tryEndOfStream();
450
+ return;
451
+ }
452
+ try {
453
+ const ab = chunk.buffer.slice(
454
+ chunk.byteOffset,
455
+ chunk.byteOffset + chunk.byteLength,
456
+ ) as ArrayBuffer;
457
+ sourceBuffer.appendBuffer(ab);
458
+ } catch {
459
+ fallback();
460
+ }
461
+ };
462
+
463
+ sourceBuffer.addEventListener('updateend', () => {
464
+ if (!started && this.playbackStarted && !this.destroyed) {
465
+ started = true;
466
+ audio.play().catch(() => fallback());
467
+ }
468
+ pump();
469
+ });
470
+
471
+ // Primo pump (se arrivano subito chunk)
472
+ pump();
473
+
474
+ while (!abort.signal.aborted) {
475
+ const { value, done } = await reader.read();
476
+ if (done) {
477
+ doneReading = true;
478
+ break;
479
+ }
480
+ if (value && value.byteLength > 0) {
481
+ queue.push(value);
482
+ pump();
483
+ }
484
+ }
485
+
486
+ doneReading = true;
487
+ tryEndOfStream();
488
+ } catch {
489
+ if (!abort.signal.aborted) {
490
+ fallback();
491
+ }
492
+ }
493
+ };
494
+
495
+ mediaSource.addEventListener('sourceopen', onSourceOpen);
496
+ }
497
+
498
+ private handlePlaybackError(): void {
499
+ this.playbackStarted = false;
500
+ this.cleanupStreaming();
501
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
502
+ this.markAllWordsPast();
503
+ if (this.message) {
504
+ this.message.isJustRecived = false;
505
+ }
506
+ this.cdr.detectChanges();
507
+ }
508
+
509
+ private cleanupStreaming(): void {
510
+ try {
511
+ this.streamAbort?.abort();
512
+ } catch {
513
+ /* ignore */
514
+ }
515
+ this.streamAbort = undefined;
516
+
517
+ if (this.mediaSourceObjectUrl) {
518
+ try {
519
+ URL.revokeObjectURL(this.mediaSourceObjectUrl);
520
+ } catch {
521
+ /* ignore */
522
+ }
523
+ this.mediaSourceObjectUrl = undefined;
524
+ }
525
+ }
526
+
527
+ private getJwtToken(): string | null {
528
+ const raw = (this.globals?.tiledeskToken || this.globals?.jwt || '')
529
+ .trim()
530
+ .replace(/^(JWT|Bearer)\s+/i, '')
531
+ .trim();
532
+ return raw.length > 0 ? `JWT ${raw}` : null;
533
+ }
534
+
535
+ /**
536
+ * Extracts the Tiledesk requestId from a Chat21 recipient string.
537
+ * Format: `support-group-<projectId>-<requestId>`
538
+ */
539
+ private parseRequestId(recipient: string): string | null {
540
+ const parts = recipient.split('-');
541
+ if (parts.length < 4) return null;
542
+ return parts.slice(3).join('-') || null;
543
+ }
544
+
545
+ private getVoiceSettingsBody(): unknown {
546
+ const raw = (this.message as any)?.metadata?.voiceSettings;
547
+ if (raw === null || raw === undefined) {
548
+ return {};
549
+ }
550
+ if (typeof raw === 'string') {
551
+ const s = raw.trim();
552
+ if (!s) return {};
553
+ try {
554
+ return JSON.parse(s);
555
+ } catch {
556
+ // se non è JSON valido, invialo come stringa (il backend può gestirlo)
557
+ return { voiceSettings: raw };
558
+ }
559
+ }
560
+ return raw;
561
+ }
562
+
563
+ private async fetchAsBlobAndPlay(
564
+ audio: HTMLAudioElement,
565
+ endpoint: string,
566
+ jwt: string | null,
567
+ requestBody: unknown,
568
+ ): Promise<void> {
569
+ try {
570
+ const headers: Record<string, string> = {
571
+ 'Content-Type': 'application/json',
572
+ };
573
+ if (jwt) {
574
+ headers['Authorization'] = jwt;
575
+ }
576
+
577
+ const response = await fetch(endpoint, {
578
+ method: 'POST',
579
+ headers,
580
+ body: JSON.stringify(requestBody ?? {}),
581
+ signal: this.streamAbort?.signal,
582
+ });
583
+
584
+ if (!response.ok) {
585
+ throw new Error(`TTS request failed (${response.status})`);
586
+ }
587
+
588
+ const blob = await response.blob();
589
+ if (this.destroyed) {
590
+ return;
591
+ }
592
+
593
+ const objectUrl = URL.createObjectURL(blob);
594
+ this.mediaSourceObjectUrl = objectUrl;
595
+ audio.src = objectUrl;
596
+ audio.play().catch(() => this.handlePlaybackError());
597
+ } catch {
598
+ this.handlePlaybackError();
599
+ }
600
+ }
601
+
602
+ private fetchFullFileFromEndpoint(audio: HTMLAudioElement, endpoint: string): void {
603
+ const jwt = this.getJwtToken();
604
+ const voiceSettings = this.getVoiceSettingsBody();
605
+ const requestBody = this.buildTtsRequestBody(voiceSettings);
606
+ void this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
607
+ }
608
+
609
+ private buildTtsRequestBody(voiceSettings: unknown): unknown {
610
+ const text = this.message?.text ?? '';
611
+ const projectId = String(this.globals?.projectid ?? '').trim();
612
+ const requestId = this.parseRequestId(this.globals?.recipientId ?? '');
613
+ const base: Record<string, unknown> = { outputFormat: BROWSER_TTS_OUTPUT_FORMAT, text };
614
+ if (projectId) base['projectId'] = projectId;
615
+ if (requestId) {
616
+ base['requestId'] = requestId;
617
+ }
618
+ if (voiceSettings && typeof voiceSettings === 'object' && !Array.isArray(voiceSettings)) {
619
+ // Spread provider-specific fields (provider, voiceId, model, language, …) at top level.
620
+ // Keep `text` last so it cannot be overridden by voiceSettings.
621
+ return { ...base, ...(voiceSettings as Record<string, unknown>), text };
622
+ }
623
+ return base;
624
+ }
625
+
626
+ private markAllWordsPast(): void {
627
+ this.words.forEach((w) => {
628
+ w.state = 'past';
629
+ });
630
+ this.activeIndex = -1;
631
+ }
632
+
633
+ private estimateDurationSecondsFromText(): number {
634
+ const rawWords = (this.message?.text || '')
635
+ .trim()
636
+ .split(/\s+/)
637
+ .filter((w) => w.length > 0);
638
+ if (rawWords.length === 0) {
639
+ return 1;
640
+ }
641
+ // ~140 WPM → ~0.43s/word
642
+ return Math.max(1, rawWords.length * 0.43);
643
+ }
644
+
645
+ buildFakeTiming(): void {
646
+ const rawWords = (this.message?.text || '')
647
+ .trim()
648
+ .split(/\s+/)
649
+ .filter((w) => w.length > 0);
650
+ if (rawWords.length === 0) {
651
+ this.words = [];
652
+ return;
653
+ }
654
+ const step = this.duration / rawWords.length;
655
+
656
+ this.words = rawWords.map((w, i) => ({
657
+ text: w,
658
+ start: i * step,
659
+ end: (i + 1) * step,
660
+ state: 'future' as const,
661
+ }));
662
+ }
663
+
664
+ syncStatesFromCurrentTime(): void {
665
+ if (this.skipSyncAnimation) {
666
+ return;
667
+ }
668
+ const audio = this.audioRef?.nativeElement;
669
+ if (!audio || this.words.length === 0) {
670
+ return;
671
+ }
672
+ this.currentTime = audio.currentTime;
673
+ let newActiveIndex = -1;
674
+
675
+ this.words.forEach((w, i) => {
676
+ if (this.currentTime >= w.end) {
677
+ w.state = 'past';
678
+ } else if (this.currentTime >= w.start && this.currentTime < w.end) {
679
+ w.state = 'active';
680
+ newActiveIndex = i;
681
+ } else {
682
+ w.state = 'future';
683
+ }
684
+ });
685
+
686
+ if (newActiveIndex !== this.activeIndex) {
687
+ this.activeIndex = newActiveIndex;
688
+ this.scrollToActive();
689
+ }
690
+ }
691
+
692
+ onTimeUpdate(): void {
693
+ if (!this.playbackStarted) {
694
+ return;
695
+ }
696
+ this.syncStatesFromCurrentTime();
697
+ }
698
+
699
+ scrollToActive(): void {
700
+ const container = this.transcriptBox?.nativeElement;
701
+ const active = container?.querySelector('.active') as HTMLElement;
702
+
703
+ if (active) {
704
+ active.scrollIntoView({
705
+ behavior: 'smooth',
706
+ block: 'center',
707
+ });
708
+ }
709
+ }
710
+
711
+ trackByIndex(index: number): number {
712
+ return index;
713
+ }
714
+ }