@chat21/chat21-web-widget 5.1.32-rc4 → 5.1.32-rc9

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/CHANGELOG.md CHANGED
@@ -6,18 +6,35 @@
6
6
  ### **Copyrigth**:
7
7
  *Tiledesk SRL*
8
8
 
9
+ # 5.1.32-rc9
10
+ - **added**: mic-triggered TTS interruption — when VAD detects user speech, stop current TTS playback, clear the queue, and reveal the full message text
11
+ - **added**: global TTS cancel API (`TtsAudioPlaybackCoordinator.cancelAll()` + `cancelAll$`) to stop current + queued TTS playback from UI/events (e.g. close stream)
12
+ - **changed**: `chat-audio-sync` TTS playback now streams audio via authenticated POST to `message.metadata.src`, sending `voiceSettings` + `text` and `streaming: true`
13
+ - **changed**: stream UI spectrum — removed circular orb and stretched the spectrum line to fill the `#streamAudioAlert` width with 10px side padding
14
+ - **changed**: conversation content layout while streaming — adjusted received bubble left margin and loading spinner margins for full-size mode
15
+
16
+
17
+ # 5.1.32-rc8
18
+ - **changed**: updated the dev environment defaults to align with the stage setup (remote config URL, API endpoints, logging level, storage prefix, and related settings)
19
+
20
+ # 5.1.32-rc7
21
+ - **added**: `StreamAudioSpectrum` component for audio visualization in the streaming footer UI
22
+ - **added**: TTS playback coordinator queue — ensures TTS messages play sequentially without interrupting the previous one
23
+ - **changed**: `chat-audio-sync` — updated TTS audio handling to support streaming playback and improved autoplay/animation timing
24
+ - **changed**: iframe loader (`launch.js`, `launch_template.js`) — streamlined loading logic and improved error handling, with fixes for localhost environments
25
+
9
26
  # 5.1.32-rc4
10
- - **added**: Pulsante chiudi stream” (`.close-stream-button`) — offset del contenuto e del foglio in fullscreen usando `--chat-footer-stream-button-height` solo mentre lo stream è in ascolto (`isStreamAudioActive`); variabili in `_variables.scss`.
11
- - **added**: `VoiceService.discardCurrentRecordingSegment()` — in arrivo un messaggio da altro mittente durante lo stream, si scarta il segmento WebM corrente (nessun upload) senza chiudere mic/VAD; `interruptStreamDueToPeerMessage()` nel footer non spegne più `isStreamAudioActive`.
12
- - **changed**: `#streamAudioAlert` — fascia superiore al footer con effetto vetro satinato (`backdrop-filter`, `color-mix` semi-trasparente).
27
+ - **added**: “Close stream” control (`.close-stream-button`) — content and sheet bottom offset in fullscreen using `--chat-footer-stream-button-height` only while the stream is listening (`isStreamAudioActive`); variables in `_variables.scss`.
28
+ - **added**: `VoiceService.discardCurrentRecordingSegment()` — when a message arrives from another sender during streaming, the current WebM segment is discarded (no upload) without stopping mic/VAD; `interruptStreamDueToPeerMessage()` in the footer no longer clears `isStreamAudioActive`.
29
+ - **changed**: `#streamAudioAlert` — band above the footer with a frosted-glass look (`backdrop-filter`, semi-transparent `color-mix`).
13
30
 
14
31
  # 5.1.32-rc3
15
- - **changed**: `nginx.conf` (immagine Docker) — tipi MIME espliciti per `.mjs`, `.wasm`, `.onnx` e `default_type` a livello `http` (evita `text/plain` su moduli ONNX/VAD dietro deploy containerizzato).
16
- - **chore**: rimossi script di deploy Amazon beta/prod deprecati dal repository.
32
+ - **changed**: `nginx.conf` (Docker image) — explicit MIME types for `.mjs`, `.wasm`, `.onnx` and `default_type` at `http` level (avoids `text/plain` on ONNX/VAD modules behind containerized deploys).
33
+ - **chore**: removed deprecated Amazon beta/prod deploy scripts from the repository.
17
34
 
18
35
  # 5.1.32-rc2
19
36
  - **bug fixed**: minor streaming icon UI fixed
20
- - **changed**: Refactor UI pulsante stream audio nel conversation footer (layout / classi).
37
+ - **changed**: Refactor stream audio button UI in the conversation footer (layout / classes).
21
38
 
22
39
  # 5.1.32-rc1
23
40
  - **added**: Voice pipeline — VAD (`@ricky0123/vad-web`) with ONNX Runtime WASM served from `/assets/onnx` (`copy-onnx-wasm`), `VoiceService` with `audioSegment$` (WebM segments) and optional STT/TTS via unified OpenAI provider using `HttpClient`, transcript / error fields on segment payloads.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chat21/chat21-web-widget",
3
3
  "author": "Tiledesk SRL",
4
- "version": "5.1.32-rc4",
4
+ "version": "5.1.32-rc9",
5
5
  "license": "MIT",
6
6
  "homepage": "https://www.tiledesk.com",
7
7
  "repository": {
@@ -16,6 +16,7 @@ import { ConversationFooterComponent } from './component/conversation-detail/con
16
16
  import { ConversationInternalFrameComponent } from './component/conversation-detail/conversation-internal-frame/conversation-internal-frame.component';
17
17
  import { ConversationPreviewComponent } from './component/conversation-detail/conversation-preview/conversation-preview.component';
18
18
  import { ConversationAudioRecorderComponent } from './component/conversation-detail/conversation-audio-recorder/conversation-audio-recorder.component';
19
+ import { StreamAudioSpectrumComponent } from './component/conversation-detail/stream-audio-spectrum/stream-audio-spectrum.component';
19
20
  /** CONVERSATION-DETAIL COMPONENTS */
20
21
  import { BubbleMessageComponent } from './component/message/bubble-message/bubble-message.component';
21
22
  import { AvatarComponent } from './component/message/avatar/avatar.component';
@@ -294,6 +295,7 @@ export function uploadFactory(http: HttpClient, appConfig: AppConfigService, app
294
295
  ConversationPreviewComponent,
295
296
  ConversationInternalFrameComponent,
296
297
  ConversationAudioRecorderComponent,
298
+ StreamAudioSpectrumComponent,
297
299
  BubbleMessageComponent,
298
300
  AvatarComponent,
299
301
  FrameComponent,
@@ -49,7 +49,7 @@
49
49
  <!-- message RECIPIENT:: -->
50
50
  <div role="messaggio" *ngIf="messageType(MESSAGE_TYPE_OTHERS, message)" class="msg_container base_receive">
51
51
 
52
- <chat-avatar-image *ngIf="!isSameSender(message?.sender, i)"
52
+ <chat-avatar-image *ngIf="!isSameSender(message?.sender, i) && !isStreamAudioActive"
53
53
  [ngClass]="{'slide-in-left': false}"
54
54
  [senderID]="message?.sender"
55
55
  [senderFullname]="message?.sender_fullname"
@@ -62,7 +62,8 @@
62
62
  [ngClass]="{'slide-in-left': false}"
63
63
  [class.no-background]="(isImage(message) || isFrame(message) || isCarousel(message)) && ((message?.text && message?.text.trim() === '') || !message?.text)"
64
64
  [class.emoticon]="isEmojii(message?.text)"
65
- [style.margin-left]="isSameSender(message?.sender, i)? 'calc(var(--avatar-width) + 10px)': null"
65
+ [class.fullSizeMessage]="isStreamAudioActive"
66
+ [style.margin-left]="isSameSender(message?.sender, i) ? 'calc(var(--avatar-width) + 10px)' : null"
66
67
  [ngStyle]="{'background': stylesMap.get('bubbleReceivedBackground'), 'color': stylesMap.get('bubbleReceivedTextColor'), 'width':isFrame(message) ?'100%' : null}"
67
68
  [isSameSender]="isSameSender(message?.sender, i)"
68
69
  [message]="message"
@@ -134,9 +135,10 @@
134
135
  [senderFullname]="nameUserTypingNow"
135
136
  [baseLocation]="baseLocation">
136
137
  </chat-avatar-image>
138
+
137
139
  <user-typing
138
- [ngClass]="{'userTypingNowExist': !idUserTypingNow}"
139
140
  [color]="stylesMap?.get('iconColor')"
141
+ [ngClass]="{'userTypingNowExist': !idUserTypingNow}"
140
142
  [translationMap]="translationMap"
141
143
  [idUserTypingNow]="idUserTypingNow"
142
144
  [nameUserTypingNow]="nameUserTypingNow">
@@ -145,6 +147,7 @@
145
147
 
146
148
  <div *ngIf="showThinkingMessage && lastServerSenderKind === 'bot'" class="msg_container base_receive thinking_receive">
147
149
  <user-typing class="loading thinking-dots"
150
+ [class.fullSize]="isStreamAudioActive"
148
151
  [color]="stylesMap?.get('iconColor')"
149
152
  [translationMap]="translationMap"
150
153
  [idUserTypingNow]="idUserTypingNow"
@@ -27,6 +27,11 @@
27
27
  margin: 25px 50px
28
28
  }
29
29
 
30
+
31
+ :host .loading.fullSize ::ng-deep > div.spinner{
32
+ margin: 50px 0px !important;
33
+ }
34
+
30
35
  // ============= CSS c21-body ================= //
31
36
  .c21-body {
32
37
  // -webkit-box-shadow: inset 0 10px 10px -10px rgba(0,0,0,0.4);
@@ -236,6 +241,11 @@
236
241
  height: fit-content;
237
242
  width: auto;
238
243
 
244
+ &.fullSizeMessage {
245
+ max-width: 100%;
246
+ margin: auto 0 auto 0 !important;
247
+ }
248
+
239
249
  }
240
250
 
241
251
 
@@ -14,24 +14,10 @@
14
14
 
15
15
  <!-- STREAM AUDIO: cerchio con onde animate -->
16
16
  <div id="streamAudioAlert" *ngIf="!hideTextAreaContent && isStreamAudioActive" class="fade-in-bottom stream-audio-alert" [class.hideTextReply]="hideTextReply" role="status" [attr.aria-label]="translationMap?.get('STREAM_AUDIO_LISTENING') || 'Stream audio attivo'">
17
- <div class="stream-audio-alert__orb" [ngStyle]="{ color: stylesMap?.get('themeColor') }">
18
- <svg class="stream-audio-alert__svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
19
- <circle cx="50" cy="50" r="46" fill="currentColor" opacity="0.14"/>
20
- <g class="stream-audio-alert__waves" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
21
- <g class="stream-audio-alert__wave-layer stream-audio-alert__wave-layer--1">
22
- <path [attr.d]="wavePath1"></path>
23
- </g>
24
-
25
- <g class="stream-audio-alert__wave-layer stream-audio-alert__wave-layer--2">
26
- <path [attr.d]="wavePath2"></path>
27
- </g>
28
-
29
- <g class="stream-audio-alert__wave-layer stream-audio-alert__wave-layer--3">
30
- <path [attr.d]="wavePath3"></path>
31
- </g>
32
- </g>
33
- </svg>
34
- </div>
17
+ <chat-stream-audio-spectrum
18
+ [volume]="currentVolume"
19
+ [accentColor]="stylesMap?.get('themeColor')">
20
+ </chat-stream-audio-spectrum>
35
21
  </div>
36
22
 
37
23
  </div>
@@ -418,54 +418,6 @@ textarea:active{
418
418
  }
419
419
  }
420
420
 
421
- .stream-audio-alert__orb {
422
- display: flex;
423
- align-items: center;
424
- justify-content: center;
425
- width: 88px;
426
- height: 88px;
427
- border-radius: 50%;
428
- border: 2px solid currentColor;
429
- background: var(--content-background-color);
430
- box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.04);
431
- }
432
-
433
- .stream-audio-alert__svg {
434
- width: 72px;
435
- height: 72px;
436
- display: block;
437
- }
438
-
439
- .stream-audio-alert__wave-layer {
440
- transform-origin: 50px 50px;
441
- transform-box: fill-box;
442
- animation: stream-wave-float 1.35s ease-in-out infinite;
443
- }
444
-
445
- .stream-audio-alert__wave-layer--1 {
446
- animation-delay: 0s;
447
- }
448
-
449
- .stream-audio-alert__wave-layer--2 {
450
- animation-delay: 0.18s;
451
- }
452
-
453
- .stream-audio-alert__wave-layer--3 {
454
- animation-delay: 0.36s;
455
- }
456
-
457
- @keyframes stream-wave-float {
458
- 0%,
459
- 100% {
460
- transform: translateY(0);
461
- opacity: 0.85;
462
- }
463
- 50% {
464
- transform: translateY(-6px);
465
- opacity: 1;
466
- }
467
- }
468
-
469
421
  #textAlert{
470
422
  bottom: 100%;
471
423
  width: 100%;
@@ -17,6 +17,7 @@ import { findAndRemoveEmoji, isImage } from 'src/chat21-core/utils/utils-message
17
17
  import { ProjectModel } from 'src/models/project';
18
18
  import { Subscription } from 'rxjs';
19
19
  import { VoiceService } from 'src/app/providers/voice/voice.service';
20
+ import { TtsAudioPlaybackCoordinator } from 'src/app/providers/tts-audio-playback-coordinator.service';
20
21
 
21
22
  @Component({
22
23
  selector: 'chat-conversation-footer',
@@ -97,10 +98,8 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
97
98
  private voiceAudioSubscription?: Subscription;
98
99
  /** Sottoscrizione al volume audio (real-time) dal {@link VoiceService}. */
99
100
  private voiceVolumeSubscription?: Subscription;
101
+ /** Passato a {@link StreamAudioSpectrumComponent} per disegnare la linea spettro. */
100
102
  currentVolume = 0;
101
- wavePath1 = '';
102
- wavePath2 = '';
103
- wavePath3 = '';
104
103
 
105
104
  file_size_limit = FILE_SIZE_LIMIT;
106
105
  attachmentTooltip: string = '';
@@ -112,17 +111,18 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
112
111
  constructor(private chatManager: ChatManager,
113
112
  private typingService: TypingService,
114
113
  private uploadService: UploadService,
115
- private voiceService: VoiceService) { }
114
+ private voiceService: VoiceService,
115
+ private ttsPlayback: TtsAudioPlaybackCoordinator) { }
116
116
 
117
117
  ngOnInit() {
118
118
  // this.updateAttachmentTooltip();
119
119
  }
120
120
 
121
-
122
121
  ngOnChanges(changes: SimpleChanges){
123
122
  if(changes['conversationWith'] && changes['conversationWith'].currentValue !== undefined){
124
123
  this.conversationHandlerService = this.chatManager.getConversationHandlerByConversationId(this.conversationWith);
125
124
  this.isStreamAudioActive = false;
125
+ this.ttsPlayback.cancelAll();
126
126
  void this.stopVoice();
127
127
  }
128
128
  if(changes['hideTextReply'] && changes['hideTextReply'].currentValue !== undefined){
@@ -176,7 +176,6 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
176
176
  });
177
177
  this.voiceVolumeSubscription = this.voiceService.volume$.subscribe((volume) => {
178
178
  this.currentVolume = volume;
179
- this.updateWave(volume);
180
179
  });
181
180
  await this.voiceService.startSession();
182
181
  }
@@ -189,9 +188,11 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
189
188
  this.voiceVolumeSubscription = undefined;
190
189
 
191
190
  await this.voiceService.stopSession(options);
191
+ this.currentVolume = 0;
192
192
  }
193
193
 
194
194
  /**
195
+ * CHIAMATO DA: conversation.component.ts
195
196
  * Messaggio in arrivo da un altro mittente mentre lo stream è attivo: scarta solo il segmento
196
197
  * registrato in quel momento (nessun upload); mic + VAD restano attivi, `isStreamAudioActive` true.
197
198
  */
@@ -207,27 +208,6 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
207
208
  }
208
209
  }
209
210
 
210
- updateWave(volume: number) {
211
- const intensity = Math.min(volume / 80, 1); // più sensibile
212
-
213
- const amp1 = 4 + intensity * 22;
214
- const amp2 = 2 + intensity * 16;
215
- const amp3 = 1 + intensity * 12;
216
-
217
- this.wavePath1 = this.buildWave(42, amp1);
218
- this.wavePath2 = this.buildWave(50, amp2);
219
- this.wavePath3 = this.buildWave(58, amp3);
220
- }
221
-
222
- buildWave(y: number, amp: number): string {
223
- return `
224
- M6 ${y}
225
- Q24 ${y - amp} 42 ${y}
226
- T78 ${y}
227
- T98 ${y}
228
- `;
229
- }
230
-
231
211
  ngOnDestroy() {
232
212
  void this.stopVoice();
233
213
  }
@@ -757,15 +737,19 @@ export class ConversationFooterComponent implements OnInit, OnChanges, OnDestroy
757
737
  const turningOn = !this.isStreamAudioActive;
758
738
  if (turningOn) {
759
739
  try {
740
+ this.currentVolume = 0;
760
741
  await this.initVoice();
761
742
  this.isStreamAudioActive = true;
762
743
  } catch (e) {
763
744
  this.logger.error('[CONV-FOOTER] onStreamPressed: initVoice failed', e);
764
745
  this.isStreamAudioActive = false;
746
+ this.ttsPlayback.cancelAll();
765
747
  }
766
748
  } else {
767
749
  await this.stopVoice();
768
750
  this.isStreamAudioActive = false;
751
+ // Close-stream-button clicked: stop any playing/queued TTS audio.
752
+ this.ttsPlayback.cancelAll();
769
753
  }
770
754
  this.onStreamAudioActiveChange.emit(this.isStreamAudioActive);
771
755
  this.logger.log('[CONV-FOOTER] isStreamAudioActive', this.isStreamAudioActive);
@@ -0,0 +1,18 @@
1
+ <div class="stream-audio-spectrum" [ngStyle]="accentColor ? { color: accentColor } : null">
2
+ <svg class="stream-audio-spectrum__svg" viewBox="0 0 100 32" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
3
+ <defs>
4
+ <linearGradient [attr.id]="gradientId" x1="0" y1="16" x2="100" y2="16" gradientUnits="userSpaceOnUse">
5
+ <stop offset="0%" stop-color="currentColor" stop-opacity="0.45"/>
6
+ <stop offset="50%" stop-color="currentColor" stop-opacity="1"/>
7
+ <stop offset="100%" stop-color="currentColor" stop-opacity="0.45"/>
8
+ </linearGradient>
9
+ </defs>
10
+ <path class="stream-audio-spectrum__line"
11
+ [attr.d]="spectrumLinePath"
12
+ fill="none"
13
+ [attr.stroke]="'url(#' + gradientId + ')'"
14
+ stroke-width="2.4"
15
+ stroke-linecap="round"
16
+ stroke-linejoin="round"/>
17
+ </svg>
18
+ </div>
@@ -0,0 +1,25 @@
1
+ :host {
2
+ display: block;
3
+ width: 100%;
4
+ flex: 1 1 auto;
5
+ }
6
+
7
+ .stream-audio-spectrum {
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+ width: 100%;
12
+ padding: 0 10px;
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ .stream-audio-spectrum__svg {
17
+ width: 100%;
18
+ height: 32px;
19
+ display: block;
20
+ }
21
+
22
+ .stream-audio-spectrum__line {
23
+ pointer-events: none;
24
+ filter: drop-shadow(0 0 1px color-mix(in srgb, currentColor 35%, transparent));
25
+ }
@@ -0,0 +1,62 @@
1
+ import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
2
+
3
+ /**
4
+ * Icona stream: cerchio con linea orizzontale tipo spettro, reattiva al volume del microfono.
5
+ * Il parent (es. conversation-footer) aggiorna solo {@link volume} da VoiceService.
6
+ */
7
+ @Component({
8
+ selector: 'chat-stream-audio-spectrum',
9
+ templateUrl: './stream-audio-spectrum.component.html',
10
+ styleUrl: './stream-audio-spectrum.component.scss',
11
+ })
12
+ export class StreamAudioSpectrumComponent implements OnInit, OnChanges {
13
+ private static gradSeq = 0;
14
+ readonly gradientId = `streamSpectrumGrad-${++StreamAudioSpectrumComponent.gradSeq}`;
15
+
16
+ /** Volume normalizzato come emesso da VoiceService (stessa scala del footer). */
17
+ @Input() volume = 0;
18
+ /** Colore tema (stroke / gradient); opzionale. */
19
+ @Input() accentColor?: string;
20
+
21
+ spectrumLinePath = 'M0,16 L100,16';
22
+
23
+ ngOnInit(): void {
24
+ this.refreshPath();
25
+ }
26
+
27
+ ngOnChanges(changes: SimpleChanges): void {
28
+ if (changes['volume']) {
29
+ this.refreshPath();
30
+ }
31
+ }
32
+
33
+ private refreshPath(): void {
34
+ const intensity = Math.min(this.volume / 80, 1);
35
+ const t = Date.now() / 175;
36
+ this.spectrumLinePath = this.buildSpectrumLinePath(intensity, t);
37
+ }
38
+
39
+ private buildSpectrumLinePath(intensity: number, t: number): string {
40
+ const x0 = 0;
41
+ const x1 = 100;
42
+ const cy = 16;
43
+ const segments = 100;
44
+ const amp = 0.8 + intensity * 6.5;
45
+ const parts: string[] = [];
46
+ for (let i = 0; i <= segments; i++) {
47
+ const p = i / segments;
48
+ const x = x0 + p * (x1 - x0);
49
+ const u = p * Math.PI * 6;
50
+ const wobble =
51
+ Math.sin(u + t) * 0.34 +
52
+ Math.sin(u * 2.35 + t * 1.12) * 0.24 +
53
+ Math.sin(u * 4.2 + t * 0.72) * 0.18 +
54
+ Math.sin(u * 6.8 + t * 1.05) * 0.14 +
55
+ Math.sin(u * 9.1 + t * 0.88) * 0.1;
56
+ const y = cy + amp * wobble;
57
+ const yClamped = Math.min(30, Math.max(2, y));
58
+ parts.push(i === 0 ? `M${x.toFixed(2)},${yClamped.toFixed(2)}` : `L${x.toFixed(2)},${yClamped.toFixed(2)}`);
59
+ }
60
+ return parts.join('');
61
+ }
62
+ }
@@ -2,7 +2,6 @@
2
2
 
3
3
  <audio
4
4
  #audioPlayer
5
- [src]="message?.metadata?.src"
6
5
  (timeupdate)="onTimeUpdate()"
7
6
  style="display:none">
8
7
  </audio>
@@ -17,7 +17,6 @@
17
17
  .lyrics {
18
18
  font-size: inherit;
19
19
  margin: 0;
20
- line-height: 1.4em;
21
20
  font-style: normal;
22
21
  letter-spacing: normal;
23
22
  font-stretch: normal;
@@ -9,7 +9,11 @@ import {
9
9
  SimpleChanges,
10
10
  ViewChild,
11
11
  } from '@angular/core';
12
+ import { Subscription } from 'rxjs';
12
13
  import { MessageModel } from 'src/chat21-core/models/message';
14
+ import { TtsAudioPlaybackCoordinator } from 'src/app/providers/tts-audio-playback-coordinator.service';
15
+ import { Globals } from 'src/app/utils/globals';
16
+ import { VoiceService } from 'src/app/providers/voice/voice.service';
13
17
 
14
18
  /** HAVE_METADATA: metadati già disponibili (tipico audio servito da cache). */
15
19
  const HAVE_METADATA = 1;
@@ -41,7 +45,23 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
41
45
  private onMetadataLoaded: () => void;
42
46
  private onPlaybackEnded: () => void;
43
47
 
44
- constructor(private readonly cdr: ChangeDetectorRef) {}
48
+ /** Id univoco per il coordinatore (di solito `message.uid`). */
49
+ private playbackOwnerId = '';
50
+ private destroyed = false;
51
+ private playbackRequested = false;
52
+ private playbackStarted = false;
53
+ private micInterrupted = false;
54
+ private streamAbort?: AbortController;
55
+ private mediaSourceObjectUrl?: string;
56
+ private cancelAllSub?: Subscription;
57
+ private micSpeechSub?: Subscription;
58
+
59
+ constructor(
60
+ private readonly cdr: ChangeDetectorRef,
61
+ private readonly ttsPlayback: TtsAudioPlaybackCoordinator,
62
+ private readonly globals: Globals,
63
+ private readonly voiceService: VoiceService,
64
+ ) {}
45
65
 
46
66
  /** `false` = messaggio già in storico: niente autoplay / karaoke. Da `message.isJustRecived`. */
47
67
  private get skipSyncAnimation(): boolean {
@@ -53,11 +73,14 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
53
73
  return;
54
74
  }
55
75
  if (this.audioRef?.nativeElement && this.timingReady) {
56
- this.duration = this.audioRef.nativeElement.duration || 1;
76
+ const d = this.audioRef.nativeElement.duration;
77
+ if (Number.isFinite(d) && d > 0) {
78
+ this.duration = d;
79
+ }
57
80
  this.buildFakeTiming();
58
81
  if (this.skipSyncAnimation) {
59
82
  this.markAllWordsPast();
60
- } else {
83
+ } else if (this.playbackStarted) {
61
84
  this.syncStatesFromCurrentTime();
62
85
  }
63
86
  }
@@ -66,7 +89,36 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
66
89
  ngAfterViewInit(): void {
67
90
  const audio = this.audioRef.nativeElement;
68
91
 
92
+ this.playbackOwnerId =
93
+ (this.message?.uid && String(this.message.uid).trim()) ||
94
+ `tts-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
95
+
96
+ // Se l’utente parla al microfono mentre sta ascoltando, interrompi TUTTO (corrente + coda).
97
+ this.micSpeechSub = this.voiceService.speechStart$.subscribe(() => {
98
+ if (this.destroyed) {
99
+ return;
100
+ }
101
+ // interrompi solo se questo messaggio era in riproduzione o in attesa
102
+ if (this.playbackStarted || this.playbackRequested) {
103
+ this.micInterrupted = true;
104
+ this.ttsPlayback.cancelAll();
105
+ this.interruptPlaybackAndRevealText();
106
+ }
107
+ });
108
+
109
+ // Stop globale (es. mic) notificato dal coordinatore: ogni istanza deve fermarsi e mostrare testo intero.
110
+ this.cancelAllSub = this.ttsPlayback.cancelAll$.subscribe(() => {
111
+ if (this.destroyed) {
112
+ return;
113
+ }
114
+ this.micInterrupted = true;
115
+ this.interruptPlaybackAndRevealText();
116
+ });
117
+
69
118
  this.onPlaybackEnded = () => {
119
+ this.playbackStarted = false;
120
+ this.cleanupStreaming();
121
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
70
122
  if (this.skipSyncAnimation) {
71
123
  return;
72
124
  }
@@ -78,38 +130,84 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
78
130
  };
79
131
 
80
132
  this.onMetadataLoaded = () => {
81
- if (this.timingReady) {
82
- return;
133
+ // La durata potrebbe arrivare tardi (specie con streaming).
134
+ const d = audio.duration;
135
+ if (Number.isFinite(d) && d > 0) {
136
+ this.duration = d;
137
+ } else if (!this.timingReady) {
138
+ this.duration = this.estimateDurationSecondsFromText();
83
139
  }
140
+
84
141
  this.timingReady = true;
85
- this.duration = audio.duration || 1;
86
142
  this.buildFakeTiming();
87
143
  if (this.skipSyncAnimation) {
88
144
  this.markAllWordsPast();
89
145
  this.cdr.detectChanges();
90
146
  return;
91
147
  }
92
- this.syncStatesFromCurrentTime();
148
+ if (this.playbackStarted) {
149
+ this.syncStatesFromCurrentTime();
150
+ }
93
151
  this.cdr.detectChanges();
94
-
95
- setTimeout(() => {
96
- audio.play().catch(() => {
97
- this.syncStatesFromCurrentTime();
98
- this.cdr.detectChanges();
99
- });
100
- }, 200);
101
152
  };
102
153
 
103
154
  audio.addEventListener('loadedmetadata', this.onMetadataLoaded);
104
155
  audio.addEventListener('ended', this.onPlaybackEnded);
105
156
 
106
- if (audio.readyState >= HAVE_METADATA) {
107
- this.onMetadataLoaded();
157
+ // Prepara subito le parole (durata stimata) e poi aggiorna quando arriva la metadata reale.
158
+ this.duration = this.estimateDurationSecondsFromText();
159
+ this.timingReady = true;
160
+ this.buildFakeTiming();
161
+ if (this.skipSyncAnimation) {
162
+ this.markAllWordsPast();
163
+ this.cdr.detectChanges();
164
+ return;
108
165
  }
166
+ this.cdr.detectChanges();
167
+
168
+ setTimeout(() => {
169
+ if (this.playbackRequested || this.destroyed || this.micInterrupted) {
170
+ if (this.micInterrupted) {
171
+ this.markAllWordsPast();
172
+ if (this.message) {
173
+ this.message.isJustRecived = false;
174
+ }
175
+ this.cdr.detectChanges();
176
+ }
177
+ return;
178
+ }
179
+ this.playbackRequested = true;
180
+ this.ttsPlayback.requestStart(this.playbackOwnerId, () => {
181
+ if (this.destroyed || this.micInterrupted) {
182
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
183
+ return;
184
+ }
185
+ this.playbackStarted = true;
186
+ this.syncStatesFromCurrentTime();
187
+ this.cdr.detectChanges();
188
+ this.startPlayback(audio);
189
+ });
190
+ }, 200);
109
191
  }
110
192
 
111
193
  ngOnDestroy(): void {
194
+ this.destroyed = true;
195
+ this.playbackStarted = false;
196
+ this.cleanupStreaming();
197
+ this.cancelAllSub?.unsubscribe();
198
+ this.micSpeechSub?.unsubscribe();
199
+
112
200
  const audio = this.audioRef?.nativeElement;
201
+ if (audio) {
202
+ try {
203
+ audio.pause();
204
+ audio.currentTime = 0;
205
+ } catch {
206
+ /* ignore */
207
+ }
208
+ }
209
+ this.ttsPlayback.release(this.playbackOwnerId);
210
+
113
211
  if (!audio) {
114
212
  return;
115
213
  }
@@ -121,6 +219,292 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
121
219
  }
122
220
  }
123
221
 
222
+ private interruptPlaybackAndRevealText(): void {
223
+ this.playbackStarted = false;
224
+ this.cleanupStreaming();
225
+
226
+ const audio = this.audioRef?.nativeElement;
227
+ if (audio) {
228
+ try {
229
+ audio.pause();
230
+ audio.currentTime = 0;
231
+ } catch {
232
+ /* ignore */
233
+ }
234
+ }
235
+
236
+ // Rimuove se era in coda (o rilascia se era corrente).
237
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
238
+
239
+ // Mostra tutto il testo (niente "future" invisibili).
240
+ this.markAllWordsPast();
241
+ if (this.message) {
242
+ this.message.isJustRecived = false;
243
+ }
244
+ this.cdr.detectChanges();
245
+ }
246
+
247
+ private startPlayback(audio: HTMLAudioElement): void {
248
+ const src = (this.message as any)?.metadata?.src as string | undefined;
249
+ if (!src) {
250
+ this.playbackStarted = false;
251
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
252
+ this.markAllWordsPast();
253
+ if (this.message) {
254
+ this.message.isJustRecived = false;
255
+ }
256
+ this.cdr.detectChanges();
257
+ return;
258
+ }
259
+
260
+ if (this.message?.type === 'tts') {
261
+ this.startStreamingFromEndpoint(audio, src);
262
+ return;
263
+ }
264
+
265
+ audio.src = src;
266
+ try {
267
+ audio.currentTime = 0;
268
+ } catch {
269
+ /* ignore */
270
+ }
271
+ audio.play().catch(() => this.handlePlaybackError());
272
+ }
273
+
274
+ private startStreamingFromEndpoint(audio: HTMLAudioElement, endpoint: string): void {
275
+ this.cleanupStreaming();
276
+
277
+ const jwt = this.getJwtToken();
278
+ const voiceSettings = this.getVoiceSettingsBody();
279
+ const requestBody = this.buildTtsRequestBody(voiceSettings);
280
+ // <audio src="..."> non può inviare header/body: serve fetch().
281
+ const hasMse = typeof (window as any).MediaSource !== 'undefined';
282
+ if (!hasMse) {
283
+ this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
284
+ return;
285
+ }
286
+
287
+ const MediaSourceCtor = (window as any).MediaSource as typeof MediaSource;
288
+ const mediaSource = new MediaSourceCtor();
289
+ const objectUrl = URL.createObjectURL(mediaSource);
290
+ this.mediaSourceObjectUrl = objectUrl;
291
+ audio.src = objectUrl;
292
+
293
+ const abort = new AbortController();
294
+ this.streamAbort = abort;
295
+
296
+ const onSourceOpen = async () => {
297
+ mediaSource.removeEventListener('sourceopen', onSourceOpen);
298
+ try {
299
+ const headers: Record<string, string> = {
300
+ 'Content-Type': 'application/json',
301
+ };
302
+ if (jwt) {
303
+ headers['Authorization'] = jwt;
304
+ }
305
+
306
+ const response = await fetch(endpoint, {
307
+ method: 'POST',
308
+ headers,
309
+ body: JSON.stringify(requestBody),
310
+ signal: abort.signal,
311
+ });
312
+ if (!response.ok || !response.body) {
313
+ throw new Error(`TTS stream request failed (${response.status})`);
314
+ }
315
+
316
+ const headerType = (response.headers.get('content-type') || '').split(';')[0].trim();
317
+ const mime = (headerType && MediaSourceCtor.isTypeSupported(headerType))
318
+ ? headerType
319
+ : 'audio/mpeg';
320
+
321
+ if (!MediaSourceCtor.isTypeSupported(mime)) {
322
+ this.cleanupStreaming();
323
+ // Fallback: fetch completo e play via blob (no streaming).
324
+ this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
325
+ return;
326
+ }
327
+
328
+ const sourceBuffer = mediaSource.addSourceBuffer(mime);
329
+ sourceBuffer.mode = 'sequence';
330
+
331
+ const reader = response.body.getReader();
332
+ const queue: Uint8Array[] = [];
333
+ let doneReading = false;
334
+ let started = false;
335
+
336
+ const tryEndOfStream = () => {
337
+ if (doneReading && queue.length === 0 && !sourceBuffer.updating) {
338
+ try {
339
+ mediaSource.endOfStream();
340
+ } catch {
341
+ /* ignore */
342
+ }
343
+ }
344
+ };
345
+
346
+ const pump = () => {
347
+ if (abort.signal.aborted) {
348
+ return;
349
+ }
350
+ if (sourceBuffer.updating) {
351
+ return;
352
+ }
353
+ const chunk = queue.shift();
354
+ if (!chunk) {
355
+ tryEndOfStream();
356
+ return;
357
+ }
358
+ try {
359
+ const ab = chunk.buffer.slice(
360
+ chunk.byteOffset,
361
+ chunk.byteOffset + chunk.byteLength,
362
+ ) as ArrayBuffer;
363
+ sourceBuffer.appendBuffer(ab);
364
+ } catch {
365
+ this.cleanupStreaming();
366
+ this.fetchAsBlobAndPlay(audio, endpoint, jwt, requestBody);
367
+ }
368
+ };
369
+
370
+ sourceBuffer.addEventListener('updateend', () => {
371
+ if (!started && this.playbackStarted && !this.destroyed) {
372
+ started = true;
373
+ audio.play().catch(() => this.handlePlaybackError());
374
+ }
375
+ pump();
376
+ });
377
+
378
+ // Primo pump (se arrivano subito chunk)
379
+ pump();
380
+
381
+ while (!abort.signal.aborted) {
382
+ const { value, done } = await reader.read();
383
+ if (done) {
384
+ doneReading = true;
385
+ break;
386
+ }
387
+ if (value && value.byteLength > 0) {
388
+ queue.push(value);
389
+ pump();
390
+ }
391
+ }
392
+
393
+ doneReading = true;
394
+ tryEndOfStream();
395
+ } catch {
396
+ if (!abort.signal.aborted) {
397
+ this.handlePlaybackError();
398
+ }
399
+ }
400
+ };
401
+
402
+ mediaSource.addEventListener('sourceopen', onSourceOpen);
403
+ }
404
+
405
+ private handlePlaybackError(): void {
406
+ this.playbackStarted = false;
407
+ this.cleanupStreaming();
408
+ this.ttsPlayback.releaseIfCurrent(this.playbackOwnerId);
409
+ this.markAllWordsPast();
410
+ if (this.message) {
411
+ this.message.isJustRecived = false;
412
+ }
413
+ this.cdr.detectChanges();
414
+ }
415
+
416
+ private cleanupStreaming(): void {
417
+ try {
418
+ this.streamAbort?.abort();
419
+ } catch {
420
+ /* ignore */
421
+ }
422
+ this.streamAbort = undefined;
423
+
424
+ if (this.mediaSourceObjectUrl) {
425
+ try {
426
+ URL.revokeObjectURL(this.mediaSourceObjectUrl);
427
+ } catch {
428
+ /* ignore */
429
+ }
430
+ this.mediaSourceObjectUrl = undefined;
431
+ }
432
+ }
433
+
434
+ private getJwtToken(): string | null {
435
+ const token = (this.globals?.tiledeskToken || this.globals?.jwt || '').trim();
436
+ return token.length > 0 ? token : null;
437
+ }
438
+
439
+ private getVoiceSettingsBody(): unknown {
440
+ const raw = (this.message as any)?.metadata?.voiceSettings;
441
+ if (raw === null || raw === undefined) {
442
+ return {};
443
+ }
444
+ if (typeof raw === 'string') {
445
+ const s = raw.trim();
446
+ if (!s) return {};
447
+ try {
448
+ return JSON.parse(s);
449
+ } catch {
450
+ // se non è JSON valido, invialo come stringa (il backend può gestirlo)
451
+ return { voiceSettings: raw };
452
+ }
453
+ }
454
+ return raw;
455
+ }
456
+
457
+ private async fetchAsBlobAndPlay(
458
+ audio: HTMLAudioElement,
459
+ endpoint: string,
460
+ jwt: string | null,
461
+ requestBody: unknown,
462
+ ): Promise<void> {
463
+ try {
464
+ const headers: Record<string, string> = {
465
+ 'Content-Type': 'application/json',
466
+ };
467
+ if (jwt) {
468
+ headers['Authorization'] = jwt;
469
+ }
470
+
471
+ const response = await fetch(endpoint, {
472
+ method: 'POST',
473
+ headers,
474
+ body: JSON.stringify(requestBody ?? {}),
475
+ signal: this.streamAbort?.signal,
476
+ });
477
+
478
+ if (!response.ok) {
479
+ throw new Error(`TTS request failed (${response.status})`);
480
+ }
481
+
482
+ const blob = await response.blob();
483
+ if (this.destroyed) {
484
+ return;
485
+ }
486
+
487
+ const objectUrl = URL.createObjectURL(blob);
488
+ this.mediaSourceObjectUrl = objectUrl;
489
+ audio.src = objectUrl;
490
+ audio.play().catch(() => this.handlePlaybackError());
491
+ } catch {
492
+ this.handlePlaybackError();
493
+ }
494
+ }
495
+
496
+ private buildTtsRequestBody(voiceSettings: unknown): unknown {
497
+ const text = this.message?.text ?? '';
498
+ if (
499
+ voiceSettings &&
500
+ typeof voiceSettings === 'object' &&
501
+ !Array.isArray(voiceSettings)
502
+ ) {
503
+ return { ...(voiceSettings as Record<string, unknown>), text, streaming: true };
504
+ }
505
+ return { voiceSettings, text, streaming: true };
506
+ }
507
+
124
508
  private markAllWordsPast(): void {
125
509
  this.words.forEach((w) => {
126
510
  w.state = 'past';
@@ -128,6 +512,18 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
128
512
  this.activeIndex = -1;
129
513
  }
130
514
 
515
+ private estimateDurationSecondsFromText(): number {
516
+ const rawWords = (this.message?.text || '')
517
+ .trim()
518
+ .split(/\s+/)
519
+ .filter((w) => w.length > 0);
520
+ if (rawWords.length === 0) {
521
+ return 1;
522
+ }
523
+ // ~140 WPM → ~0.43s/word
524
+ return Math.max(1, rawWords.length * 0.43);
525
+ }
526
+
131
527
  buildFakeTiming(): void {
132
528
  const rawWords = (this.message?.text || '')
133
529
  .trim()
@@ -176,6 +572,9 @@ export class AudioSyncComponent implements AfterViewInit, OnChanges, OnDestroy {
176
572
  }
177
573
 
178
574
  onTimeUpdate(): void {
575
+ if (!this.playbackStarted) {
576
+ return;
577
+ }
179
578
  this.syncStatesFromCurrentTime();
180
579
  }
181
580
 
@@ -0,0 +1,86 @@
1
+ import { Injectable } from '@angular/core';
2
+ import { Observable, Subject } from 'rxjs';
3
+
4
+ /**
5
+ * Garantisce un solo messaggio TTS in riproduzione alla volta.
6
+ * Se arrivano più messaggi TTS, vengono riprodotti in coda (FIFO) senza interrompere quello corrente.
7
+ */
8
+ @Injectable({ providedIn: 'root' })
9
+ export class TtsAudioPlaybackCoordinator {
10
+ private currentOwnerId: string | null = null;
11
+ private readonly queue: Array<{ ownerId: string; start: () => void }> = [];
12
+
13
+ private readonly cancelAllSource = new Subject<void>();
14
+ /** Emesso quando la riproduzione TTS va interrotta globalmente (es. l’utente parla al microfono). */
15
+ readonly cancelAll$: Observable<void> = this.cancelAllSource.asObservable();
16
+
17
+ /**
18
+ * Richiede l'avvio della riproduzione TTS per `ownerId`.
19
+ * Se non c'è nessun TTS attivo, parte subito; altrimenti viene messo in coda.
20
+ */
21
+ requestStart(ownerId: string, start: () => void): void {
22
+ const id = (ownerId || '').trim();
23
+ if (!id) {
24
+ return;
25
+ }
26
+ if (this.currentOwnerId === id) {
27
+ return;
28
+ }
29
+ if (this.queue.some((j) => j.ownerId === id)) {
30
+ return;
31
+ }
32
+ if (this.currentOwnerId) {
33
+ this.queue.push({ ownerId: id, start });
34
+ return;
35
+ }
36
+ this.currentOwnerId = id;
37
+ try {
38
+ start();
39
+ } catch {
40
+ this.releaseIfCurrent(id);
41
+ }
42
+ }
43
+
44
+ /** Chiamare a fine riproduzione naturale (`ended`) se questo messaggio era ancora “attivo”. */
45
+ releaseIfCurrent(ownerId: string): void {
46
+ const id = (ownerId || '').trim();
47
+ if (!id) {
48
+ return;
49
+ }
50
+ if (this.currentOwnerId !== id) {
51
+ // Se era in coda, rimuovilo.
52
+ const idx = this.queue.findIndex((j) => j.ownerId === id);
53
+ if (idx !== -1) {
54
+ this.queue.splice(idx, 1);
55
+ }
56
+ return;
57
+ }
58
+
59
+ this.currentOwnerId = null;
60
+ const next = this.queue.shift();
61
+ if (!next) {
62
+ return;
63
+ }
64
+ this.currentOwnerId = next.ownerId;
65
+ try {
66
+ next.start();
67
+ } catch {
68
+ this.releaseIfCurrent(next.ownerId);
69
+ }
70
+ }
71
+
72
+ /** Distruzione componente o stop esplicito. */
73
+ release(ownerId: string): void {
74
+ this.releaseIfCurrent(ownerId);
75
+ }
76
+
77
+ /**
78
+ * Interrompe TUTTA la riproduzione TTS (corrente + coda) e notifica i componenti.
79
+ * I componenti devono fermare l’audio e mostrare il testo per intero.
80
+ */
81
+ cancelAll(): void {
82
+ this.queue.splice(0, this.queue.length);
83
+ this.currentOwnerId = null;
84
+ this.cancelAllSource.next();
85
+ }
86
+ }
@@ -33,6 +33,10 @@ export class VoiceService {
33
33
  /** Emesso a ogni fine segmento parlato: audio WebM + opzionalmente `transcript` / `transcriptionError`. */
34
34
  readonly audioSegment$: Observable<VoiceSegmentPayload> = this.audioSegmentSubject.asObservable();
35
35
 
36
+ private readonly speechStartSubject = new Subject<void>();
37
+ /** Emesso quando il microfono intercetta parlato (VAD speech start). */
38
+ readonly speechStart$: Observable<void> = this.speechStartSubject.asObservable();
39
+
36
40
  // 🔊 REALTIME VOLUME STREAM
37
41
  private readonly volumeSubject = new BehaviorSubject<number>(0);
38
42
  readonly volume$: Observable<number> = this.volumeSubject.asObservable();
@@ -83,6 +87,7 @@ export class VoiceService {
83
87
  },
84
88
  onSpeechStart: () => {
85
89
  this.logger.log('[VoiceService] speech start');
90
+ this.speechStartSubject.next();
86
91
  this.startMediaRecorderSegment();
87
92
  },
88
93
  onSpeechEnd: () => {
@@ -38,7 +38,7 @@
38
38
  --chat-footer-logo-height: 30px;
39
39
  --chat-footer-close-button-height: 30px;
40
40
  --chat-footer-border-radius: 16px;
41
- --chat-footer-stream-button-height: 96px;
41
+ --chat-footer-stream-button-height: 50px;
42
42
  --chat-footer-stream-button-padding: 10px 0;
43
43
  --chat-footer-background-color: #f6f7fb;
44
44
  --chat-footer-color: #1a1a1a;
package/src/launch.js CHANGED
@@ -218,67 +218,76 @@ function loadIframe(tiledeskScriptBaseLocation) {
218
218
  iDiv.appendChild(ifrm);
219
219
 
220
220
  // Funzione helper per caricare iframe con fallback per compatibilità CSP (Wix, etc.)
221
- // Usa Blob URL come metodo principale (più compatibile con CSP) con fallback a srcdoc e document.write
222
- function loadIframeContent(iframe, htmlContent, baseLocation) {
223
- var isLocalhost = baseLocation.includes('localhost');
221
+ // Priorità: document.write / srcdoc prima della Blob URL. Le Blob URL spesso danno origine opaca
222
+ // (blob:null): l'iframe non può leggere window.parent.tiledeskSettings → projectid mancante.
223
+ function loadIframeContent(iframe, htmlContent) {
224
224
  var blobUrl = null;
225
-
226
- // Metodo 1: Blob URL (più compatibile con CSP di Wix e altre piattaforme)
227
- // Usa Blob URL come metodo principale perché è meno spesso bloccato da CSP rispetto a srcdoc
225
+
226
+ // 1) document.write: iframe stessa origine della pagina host tiledeskSettings sul parent accessibile
227
+ try {
228
+ var cw = iframe.contentWindow;
229
+ if (cw && cw.document) {
230
+ cw.document.open();
231
+ cw.document.write(htmlContent);
232
+ cw.document.close();
233
+ return;
234
+ }
235
+ } catch (e) {
236
+ console.warn('[Tiledesk] iframe document.write failed, trying srcdoc/blob:', e);
237
+ }
238
+
239
+ // 2) srcdoc: stessa origine del parent (HTML5); utile se document.write è bloccato
240
+ if ('srcdoc' in iframe) {
241
+ try {
242
+ iframe.srcdoc = htmlContent;
243
+ return;
244
+ } catch (e) {
245
+ console.warn('[Tiledesk] iframe srcdoc failed, trying blob:', e);
246
+ }
247
+ }
248
+
249
+ // 3) Blob URL (spesso permesso da CSP dove srcdoc/write no; può rompere lettura parent.tiledeskSettings)
228
250
  if (typeof Blob !== 'undefined' && typeof URL !== 'undefined' && URL.createObjectURL) {
229
251
  try {
230
252
  var blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
231
253
  blobUrl = URL.createObjectURL(blob);
232
254
  iframe.src = blobUrl;
233
-
234
- // Cleanup del blob URL dopo il caricamento per liberare memoria
255
+
235
256
  var originalOnload = iframe.onload;
236
257
  iframe.onload = function() {
237
- // Revoca il blob URL dopo un delay per assicurarsi che tutto sia caricato
238
258
  setTimeout(function() {
239
259
  if (blobUrl) {
240
260
  try {
241
261
  URL.revokeObjectURL(blobUrl);
242
262
  blobUrl = null;
243
- } catch(e) {
244
- console.warn('Error revoking blob URL:', e);
263
+ } catch (err) {
264
+ console.warn('Error revoking blob URL:', err);
245
265
  }
246
266
  }
247
267
  }, 1000);
248
268
  if (originalOnload) originalOnload.call(this);
249
269
  };
250
- return; // Blob URL impostato con successo
251
- } catch(e) {
252
- console.warn('Blob URL not available, trying srcdoc:', e);
270
+ return;
271
+ } catch (e) {
272
+ console.warn('Blob URL not available:', e);
253
273
  }
254
274
  }
255
-
256
- // Metodo 2: srcdoc (fallback se Blob URL non disponibile)
257
- // Skip per localhost (usa document.write per compatibilità sviluppo)
258
- if (!isLocalhost && 'srcdoc' in iframe) {
259
- try {
260
- iframe.srcdoc = htmlContent;
261
- return; // srcdoc impostato
262
- } catch(e) {
263
- console.warn('srcdoc not allowed, trying document.write:', e);
264
- }
265
- }
266
-
267
- // Metodo 3: document.write (fallback finale, funziona su localhost e browser vecchi)
268
- if (isLocalhost || (iframe.contentWindow && iframe.contentWindow.document)) {
275
+
276
+ // 4) Ultimo tentativo document.write (iframe magari non pronto al primo passo)
277
+ if (iframe.contentWindow && iframe.contentWindow.document) {
269
278
  try {
270
279
  iframe.contentWindow.document.open();
271
280
  iframe.contentWindow.document.write(htmlContent);
272
281
  iframe.contentWindow.document.close();
273
- return; // document.write completato
274
- } catch(e) {
275
- console.error('All iframe loading methods failed:', e);
282
+ return;
283
+ } catch (e) {
284
+ console.error('[Tiledesk] All iframe loading methods failed:', e);
276
285
  }
277
286
  }
278
287
  }
279
288
 
280
289
  // Carica il contenuto dell'iframe con fallback automatico
281
- loadIframeContent(ifrm, srcTileDesk, tiledeskScriptBaseLocation);
290
+ loadIframeContent(ifrm, srcTileDesk);
282
291
 
283
292
 
284
293
  }
@@ -219,67 +219,76 @@ function loadIframe(tiledeskScriptBaseLocation) {
219
219
  iDiv.appendChild(ifrm);
220
220
 
221
221
  // Funzione helper per caricare iframe con fallback per compatibilità CSP (Wix, etc.)
222
- // Usa Blob URL come metodo principale (più compatibile con CSP) con fallback a srcdoc e document.write
223
- function loadIframeContent(iframe, htmlContent, baseLocation) {
224
- var isLocalhost = baseLocation.includes('localhost');
222
+ // Priorità: document.write / srcdoc prima della Blob URL. Le Blob URL spesso danno origine opaca
223
+ // (blob:null): l'iframe non può leggere window.parent.tiledeskSettings → projectid mancante.
224
+ function loadIframeContent(iframe, htmlContent) {
225
225
  var blobUrl = null;
226
-
227
- // Metodo 1: Blob URL (più compatibile con CSP di Wix e altre piattaforme)
228
- // Usa Blob URL come metodo principale perché è meno spesso bloccato da CSP rispetto a srcdoc
226
+
227
+ // 1) document.write: iframe stessa origine della pagina host tiledeskSettings sul parent accessibile
228
+ try {
229
+ var cw = iframe.contentWindow;
230
+ if (cw && cw.document) {
231
+ cw.document.open();
232
+ cw.document.write(htmlContent);
233
+ cw.document.close();
234
+ return;
235
+ }
236
+ } catch (e) {
237
+ console.warn('[Tiledesk] iframe document.write failed, trying srcdoc/blob:', e);
238
+ }
239
+
240
+ // 2) srcdoc: stessa origine del parent (HTML5); utile se document.write è bloccato
241
+ if ('srcdoc' in iframe) {
242
+ try {
243
+ iframe.srcdoc = htmlContent;
244
+ return;
245
+ } catch (e) {
246
+ console.warn('[Tiledesk] iframe srcdoc failed, trying blob:', e);
247
+ }
248
+ }
249
+
250
+ // 3) Blob URL (spesso permesso da CSP dove srcdoc/write no; può rompere lettura parent.tiledeskSettings)
229
251
  if (typeof Blob !== 'undefined' && typeof URL !== 'undefined' && URL.createObjectURL) {
230
252
  try {
231
253
  var blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
232
254
  blobUrl = URL.createObjectURL(blob);
233
255
  iframe.src = blobUrl;
234
-
235
- // Cleanup del blob URL dopo il caricamento per liberare memoria
256
+
236
257
  var originalOnload = iframe.onload;
237
258
  iframe.onload = function() {
238
- // Revoca il blob URL dopo un delay per assicurarsi che tutto sia caricato
239
259
  setTimeout(function() {
240
260
  if (blobUrl) {
241
261
  try {
242
262
  URL.revokeObjectURL(blobUrl);
243
263
  blobUrl = null;
244
- } catch(e) {
245
- console.warn('Error revoking blob URL:', e);
264
+ } catch (err) {
265
+ console.warn('Error revoking blob URL:', err);
246
266
  }
247
267
  }
248
268
  }, 1000);
249
269
  if (originalOnload) originalOnload.call(this);
250
270
  };
251
- return; // Blob URL impostato con successo
252
- } catch(e) {
253
- console.warn('Blob URL not available, trying srcdoc:', e);
254
- }
255
- }
256
-
257
- // Metodo 2: srcdoc (fallback se Blob URL non disponibile)
258
- // Skip per localhost (usa document.write per compatibilità sviluppo)
259
- if (!isLocalhost && 'srcdoc' in iframe) {
260
- try {
261
- iframe.srcdoc = htmlContent;
262
- return; // srcdoc impostato
263
- } catch(e) {
264
- console.warn('srcdoc not allowed, trying document.write:', e);
271
+ return;
272
+ } catch (e) {
273
+ console.warn('Blob URL not available:', e);
265
274
  }
266
275
  }
267
-
268
- // Metodo 3: document.write (fallback finale, funziona su localhost e browser vecchi)
269
- if (isLocalhost || (iframe.contentWindow && iframe.contentWindow.document)) {
276
+
277
+ // 4) Ultimo tentativo document.write (iframe magari non pronto al primo passo)
278
+ if (iframe.contentWindow && iframe.contentWindow.document) {
270
279
  try {
271
280
  iframe.contentWindow.document.open();
272
281
  iframe.contentWindow.document.write(htmlContent);
273
282
  iframe.contentWindow.document.close();
274
- return; // document.write completato
275
- } catch(e) {
276
- console.error('All iframe loading methods failed:', e);
283
+ return;
284
+ } catch (e) {
285
+ console.error('[Tiledesk] All iframe loading methods failed:', e);
277
286
  }
278
287
  }
279
288
  }
280
289
 
281
290
  // Carica il contenuto dell'iframe con fallback automatico
282
- loadIframeContent(ifrm, srcTileDesk, tiledeskScriptBaseLocation);
291
+ loadIframeContent(ifrm, srcTileDesk);
283
292
 
284
293
 
285
294
  }