@pie-players/pie-tool-text-to-speech 0.1.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.
@@ -0,0 +1,660 @@
1
+ <svelte:options
2
+ customElement={{
3
+ tag: 'pie-tool-text-to-speech',
4
+ shadow: 'none',
5
+ props: {
6
+ visible: { type: 'Boolean', attribute: 'visible' },
7
+ toolId: { type: 'String', attribute: 'tool-id' },
8
+ coordinator: { type: 'Object' }
9
+ }
10
+ }}
11
+ />
12
+
13
+ <script lang="ts">
14
+
15
+ import type { IToolCoordinator, ITTSService } from '@pie-players/pie-assessment-toolkit';
16
+ import { BrowserTTSProvider, TTSService, ZIndexLayer } from '@pie-players/pie-assessment-toolkit';
17
+ import { onDestroy, onMount } from 'svelte';
18
+
19
+ // Props
20
+ let {
21
+ visible = false,
22
+ toolId = 'textToSpeech',
23
+ coordinator,
24
+ ttsService
25
+ }: {
26
+ visible?: boolean;
27
+ toolId?: string;
28
+ coordinator?: IToolCoordinator;
29
+ ttsService: ITTSService;
30
+ } = $props();
31
+
32
+ // Check if running in browser
33
+ const isBrowser = typeof window !== 'undefined';
34
+
35
+ // State
36
+ let containerEl = $state<HTMLDivElement | undefined>();
37
+ let isDragging = $state(false);
38
+ let position = $state({
39
+ x: isBrowser ? window.innerWidth - 320 : 400,
40
+ y: isBrowser ? 100 : 100
41
+ });
42
+ let dragStart = $state({ x: 0, y: 0 });
43
+
44
+ // TTS state
45
+ let isInitialized = $state(false);
46
+ let isSpeaking = $state(false);
47
+ let isPaused = $state(false);
48
+ let selectedText = $state('');
49
+ let rate = $state(1.0);
50
+ let hasSelection = $state(false);
51
+ let initError = $state<string | null>(null);
52
+
53
+ // Track registration state
54
+ let registered = $state(false);
55
+
56
+ // Register with coordinator when it becomes available
57
+ $effect(() => {
58
+ if (coordinator && toolId && !registered) {
59
+ coordinator.registerTool(toolId, 'Text-to-Speech', undefined, ZIndexLayer.MODAL);
60
+ registered = true;
61
+ }
62
+ });
63
+
64
+ // Initialize and handle lifecycle
65
+ onMount(async () => {
66
+ if (!isBrowser) return;
67
+
68
+ try {
69
+ const provider = new BrowserTTSProvider();
70
+ await ttsService.initialize(provider);
71
+ isInitialized = true;
72
+ } catch (error) {
73
+ console.error('[TTSTool] Failed to initialize TTS:', error);
74
+ initError = error instanceof Error ? error.message : 'Failed to initialize TTS';
75
+ }
76
+
77
+ // Listen for text selection changes
78
+ document.addEventListener('selectionchange', handleSelectionChange);
79
+
80
+ return () => {
81
+ if (isBrowser) {
82
+ document.removeEventListener('selectionchange', handleSelectionChange);
83
+ ttsService.stop();
84
+ }
85
+ if (coordinator && toolId) {
86
+ coordinator.unregisterTool(toolId);
87
+ }
88
+ };
89
+ });
90
+
91
+ // Update element reference when container becomes available
92
+ $effect(() => {
93
+ if (coordinator && containerEl && toolId) {
94
+ coordinator.updateToolElement(toolId, containerEl);
95
+ }
96
+ });
97
+
98
+ // Handle text selection
99
+ function handleSelectionChange() {
100
+ const selection = window.getSelection();
101
+ if (selection && selection.toString().trim().length > 0) {
102
+ selectedText = selection.toString().trim();
103
+ hasSelection = true;
104
+ } else {
105
+ hasSelection = false;
106
+ }
107
+ }
108
+
109
+ // Speak selected text
110
+ async function speakSelection() {
111
+ if (!isInitialized || !hasSelection || !selectedText) return;
112
+
113
+ try {
114
+ const selection = window.getSelection();
115
+ if (!selection || selection.rangeCount === 0) return;
116
+
117
+ const range = selection.getRangeAt(0);
118
+ const container = range.commonAncestorContainer.parentElement;
119
+
120
+ if (!container) return;
121
+
122
+ isSpeaking = true;
123
+ isPaused = false;
124
+
125
+ // Set the root element for highlighting
126
+ ttsService.setRootElement(container);
127
+
128
+ // Detect catalog ID from selected content (for SSML lookup)
129
+ const catalogId = container
130
+ .closest('[data-catalog-id]')
131
+ ?.getAttribute('data-catalog-id') || undefined;
132
+
133
+ await ttsService.speak(selectedText, {
134
+ catalogId, // Pass catalog ID for SSML resolution
135
+ rate,
136
+ highlightWords: true
137
+ }, {
138
+ onEnd: () => {
139
+ isSpeaking = false;
140
+ isPaused = false;
141
+ },
142
+ onError: (error) => {
143
+ console.error('[TTSTool] TTS error:', error);
144
+ isSpeaking = false;
145
+ isPaused = false;
146
+ }
147
+ });
148
+ } catch (error) {
149
+ console.error('[TTSTool] Failed to speak:', error);
150
+ isSpeaking = false;
151
+ isPaused = false;
152
+ }
153
+ }
154
+
155
+ // Pause/Resume
156
+ function togglePause() {
157
+ if (!isSpeaking) return;
158
+
159
+ if (isPaused) {
160
+ ttsService.resume();
161
+ isPaused = false;
162
+ } else {
163
+ ttsService.pause();
164
+ isPaused = true;
165
+ }
166
+ }
167
+
168
+ // Stop
169
+ function stopSpeaking() {
170
+ ttsService.stop();
171
+ isSpeaking = false;
172
+ isPaused = false;
173
+ }
174
+
175
+ // Update rate
176
+ function handleRateChange(event: Event) {
177
+ const target = event.target as HTMLInputElement;
178
+ rate = parseFloat(target.value);
179
+ }
180
+
181
+ // Dragging
182
+ function handlePointerDown(e: PointerEvent) {
183
+ const target = e.target as HTMLElement;
184
+
185
+ // Don't start dragging if clicking buttons or controls
186
+ if (target.closest('button, input, select')) {
187
+ return;
188
+ }
189
+
190
+ startDragging(e);
191
+ }
192
+
193
+ function startDragging(e: PointerEvent) {
194
+ if (!containerEl) return;
195
+
196
+ containerEl.setPointerCapture(e.pointerId);
197
+
198
+ isDragging = true;
199
+ dragStart = {
200
+ x: e.clientX - position.x,
201
+ y: e.clientY - position.y
202
+ };
203
+
204
+ coordinator?.bringToFront(containerEl);
205
+
206
+ containerEl.addEventListener('pointermove', handlePointerMove);
207
+ containerEl.addEventListener('pointerup', handlePointerUp);
208
+
209
+ e.preventDefault();
210
+ }
211
+
212
+ function handlePointerMove(e: PointerEvent) {
213
+ if (!isDragging) return;
214
+
215
+ position = {
216
+ x: e.clientX - dragStart.x,
217
+ y: e.clientY - dragStart.y
218
+ };
219
+
220
+ e.preventDefault();
221
+ }
222
+
223
+ function handlePointerUp(e: PointerEvent) {
224
+ if (isDragging && containerEl) {
225
+ containerEl.releasePointerCapture(e.pointerId);
226
+ isDragging = false;
227
+
228
+ containerEl.removeEventListener('pointermove', handlePointerMove);
229
+ containerEl.removeEventListener('pointerup', handlePointerUp);
230
+ }
231
+ }
232
+
233
+ function handleClose() {
234
+ coordinator?.hideTool(toolId);
235
+ }
236
+
237
+ // Get rate label
238
+ const rateLabel = $derived(
239
+ rate === 0.5 ? 'Slow' :
240
+ rate === 0.75 ? 'Slower' :
241
+ rate === 1.0 ? 'Normal' :
242
+ rate === 1.25 ? 'Faster' :
243
+ rate === 1.5 ? 'Fast' :
244
+ rate === 2.0 ? 'Very Fast' :
245
+ `${rate}x`
246
+ );
247
+ </script>
248
+
249
+ {#if visible && isBrowser}
250
+ <div
251
+ bind:this={containerEl}
252
+ class="tool-tts"
253
+ style="left: {position.x}px; top: {position.y}px;"
254
+ onpointerdown={handlePointerDown}
255
+ role="dialog"
256
+ aria-label="Text-to-Speech Tool"
257
+ >
258
+ <!-- Header -->
259
+ <div class="tool-header">
260
+ <div class="tool-header-left">
261
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
262
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.858 18.142a3 3 0 104.243-4.242L12 12.142 7.757 7.899a3 3 0 000 4.242z"/>
263
+ </svg>
264
+ <span class="tool-title">Text-to-Speech</span>
265
+ </div>
266
+ <button
267
+ class="close-button"
268
+ onclick={handleClose}
269
+ aria-label="Close"
270
+ type="button"
271
+ >
272
+ ×
273
+ </button>
274
+ </div>
275
+
276
+ <!-- Content -->
277
+ <div class="tool-content">
278
+ {#if initError}
279
+ <div class="error-message">
280
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
281
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
282
+ </svg>
283
+ <span>{initError}</span>
284
+ </div>
285
+ {:else if !isInitialized}
286
+ <div class="loading-message">
287
+ <span>Initializing...</span>
288
+ </div>
289
+ {:else}
290
+ <!-- Instructions -->
291
+ <div class="instructions">
292
+ {#if hasSelection}
293
+ <div class="selection-info">
294
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
295
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
296
+ </svg>
297
+ <span>{selectedText.length} characters selected</span>
298
+ </div>
299
+ {:else}
300
+ <p>Select text on the page to read it aloud.</p>
301
+ {/if}
302
+ </div>
303
+
304
+ <!-- Speed Control -->
305
+ <div class="control-group">
306
+ <label for="tts-speed">
307
+ <span>Speed:</span>
308
+ <strong>{rateLabel}</strong>
309
+ </label>
310
+ <input
311
+ id="tts-speed"
312
+ type="range"
313
+ min="0.5"
314
+ max="2.0"
315
+ step="0.25"
316
+ value={rate}
317
+ oninput={handleRateChange}
318
+ disabled={isSpeaking}
319
+ />
320
+ </div>
321
+
322
+ <!-- Playback Controls -->
323
+ <div class="playback-controls">
324
+ <button
325
+ class="btn-primary"
326
+ onclick={speakSelection}
327
+ disabled={!hasSelection || isSpeaking}
328
+ aria-label="Play"
329
+ type="button"
330
+ >
331
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
332
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
333
+ </svg>
334
+ <span>Play</span>
335
+ </button>
336
+
337
+ <button
338
+ class="btn-secondary"
339
+ onclick={togglePause}
340
+ disabled={!isSpeaking}
341
+ aria-label={isPaused ? 'Resume' : 'Pause'}
342
+ type="button"
343
+ >
344
+ {#if isPaused}
345
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
346
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
347
+ </svg>
348
+ <span>Resume</span>
349
+ {:else}
350
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
351
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
352
+ </svg>
353
+ <span>Pause</span>
354
+ {/if}
355
+ </button>
356
+
357
+ <button
358
+ class="btn-secondary"
359
+ onclick={stopSpeaking}
360
+ disabled={!isSpeaking}
361
+ aria-label="Stop"
362
+ type="button"
363
+ >
364
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
365
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd" />
366
+ </svg>
367
+ <span>Stop</span>
368
+ </button>
369
+ </div>
370
+
371
+ <!-- Status indicator -->
372
+ {#if isSpeaking}
373
+ <div class="status-indicator" class:paused={isPaused}>
374
+ <div class="status-icon">
375
+ {#if isPaused}
376
+
377
+ {:else}
378
+ <span class="pulse"></span>
379
+ {/if}
380
+ </div>
381
+ <span>{isPaused ? 'Paused' : 'Speaking...'}</span>
382
+ </div>
383
+ {/if}
384
+ {/if}
385
+ </div>
386
+ </div>
387
+ {/if}
388
+
389
+ <style>
390
+ .tool-tts {
391
+ position: fixed;
392
+ width: 300px;
393
+ background: white;
394
+ border: 1px solid #cbd5e0;
395
+ border-radius: 8px;
396
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
397
+ cursor: move;
398
+ user-select: none;
399
+ font-family: system-ui, -apple-system, sans-serif;
400
+ }
401
+
402
+ .tool-header {
403
+ display: flex;
404
+ justify-content: space-between;
405
+ align-items: center;
406
+ padding: 12px 16px;
407
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
408
+ color: white;
409
+ border-radius: 8px 8px 0 0;
410
+ cursor: move;
411
+ }
412
+
413
+ .tool-header-left {
414
+ display: flex;
415
+ align-items: center;
416
+ gap: 8px;
417
+ }
418
+
419
+ .tool-title {
420
+ font-weight: 600;
421
+ font-size: 14px;
422
+ }
423
+
424
+ .close-button {
425
+ background: rgba(255, 255, 255, 0.2);
426
+ border: none;
427
+ color: white;
428
+ width: 24px;
429
+ height: 24px;
430
+ border-radius: 4px;
431
+ cursor: pointer;
432
+ font-size: 20px;
433
+ line-height: 1;
434
+ display: flex;
435
+ align-items: center;
436
+ justify-content: center;
437
+ transition: background-color 0.2s;
438
+ }
439
+
440
+ .close-button:hover {
441
+ background: rgba(255, 255, 255, 0.3);
442
+ }
443
+
444
+ .tool-content {
445
+ padding: 16px;
446
+ }
447
+
448
+ .error-message,
449
+ .loading-message {
450
+ display: flex;
451
+ align-items: center;
452
+ gap: 8px;
453
+ padding: 12px;
454
+ border-radius: 6px;
455
+ font-size: 13px;
456
+ }
457
+
458
+ .error-message {
459
+ background: #fee;
460
+ color: #c33;
461
+ border: 1px solid #fcc;
462
+ }
463
+
464
+ .loading-message {
465
+ background: #f0f4f8;
466
+ color: #4a5568;
467
+ justify-content: center;
468
+ }
469
+
470
+ .instructions {
471
+ margin-bottom: 16px;
472
+ font-size: 13px;
473
+ color: #4a5568;
474
+ }
475
+
476
+ .instructions p {
477
+ margin: 0;
478
+ line-height: 1.5;
479
+ }
480
+
481
+ .selection-info {
482
+ display: flex;
483
+ align-items: center;
484
+ gap: 6px;
485
+ padding: 8px 12px;
486
+ background: #e6fffa;
487
+ border: 1px solid #81e6d9;
488
+ border-radius: 6px;
489
+ color: #234e52;
490
+ font-size: 12px;
491
+ }
492
+
493
+ .selection-info svg {
494
+ flex-shrink: 0;
495
+ }
496
+
497
+ .control-group {
498
+ margin-bottom: 16px;
499
+ }
500
+
501
+ .control-group label {
502
+ display: flex;
503
+ justify-content: space-between;
504
+ align-items: center;
505
+ margin-bottom: 8px;
506
+ font-size: 13px;
507
+ color: #4a5568;
508
+ }
509
+
510
+ .control-group label strong {
511
+ color: #667eea;
512
+ font-weight: 600;
513
+ }
514
+
515
+ .control-group input[type="range"] {
516
+ width: 100%;
517
+ height: 6px;
518
+ border-radius: 3px;
519
+ background: #e2e8f0;
520
+ outline: none;
521
+ -webkit-appearance: none;
522
+ }
523
+
524
+ .control-group input[type="range"]::-webkit-slider-thumb {
525
+ -webkit-appearance: none;
526
+ width: 18px;
527
+ height: 18px;
528
+ border-radius: 50%;
529
+ background: #667eea;
530
+ cursor: pointer;
531
+ transition: all 0.2s;
532
+ }
533
+
534
+ .control-group input[type="range"]::-webkit-slider-thumb:hover {
535
+ background: #764ba2;
536
+ transform: scale(1.1);
537
+ }
538
+
539
+ .control-group input[type="range"]:disabled {
540
+ opacity: 0.5;
541
+ cursor: not-allowed;
542
+ }
543
+
544
+ .playback-controls {
545
+ display: flex;
546
+ gap: 8px;
547
+ margin-bottom: 12px;
548
+ }
549
+
550
+ .playback-controls button {
551
+ flex: 1;
552
+ display: flex;
553
+ align-items: center;
554
+ justify-content: center;
555
+ gap: 6px;
556
+ padding: 10px 12px;
557
+ border: none;
558
+ border-radius: 6px;
559
+ font-size: 13px;
560
+ font-weight: 500;
561
+ cursor: pointer;
562
+ transition: all 0.2s;
563
+ }
564
+
565
+ .playback-controls button svg {
566
+ flex-shrink: 0;
567
+ }
568
+
569
+ .btn-primary {
570
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
571
+ color: white;
572
+ }
573
+
574
+ .btn-primary:hover:not(:disabled) {
575
+ transform: translateY(-1px);
576
+ box-shadow: 0 4px 6px rgba(102, 126, 234, 0.3);
577
+ }
578
+
579
+ .btn-secondary {
580
+ background: #f7fafc;
581
+ color: #4a5568;
582
+ border: 1px solid #e2e8f0;
583
+ }
584
+
585
+ .btn-secondary:hover:not(:disabled) {
586
+ background: #edf2f7;
587
+ }
588
+
589
+ .playback-controls button:disabled {
590
+ opacity: 0.5;
591
+ cursor: not-allowed;
592
+ transform: none !important;
593
+ }
594
+
595
+ .status-indicator {
596
+ display: flex;
597
+ align-items: center;
598
+ gap: 8px;
599
+ padding: 8px 12px;
600
+ background: #f0fdf4;
601
+ border: 1px solid #86efac;
602
+ border-radius: 6px;
603
+ font-size: 12px;
604
+ color: #166534;
605
+ animation: fadeIn 0.3s;
606
+ }
607
+
608
+ .status-indicator.paused {
609
+ background: #fef3c7;
610
+ border-color: #fcd34d;
611
+ color: #92400e;
612
+ }
613
+
614
+ .status-icon {
615
+ display: flex;
616
+ align-items: center;
617
+ justify-content: center;
618
+ }
619
+
620
+ .pulse {
621
+ width: 8px;
622
+ height: 8px;
623
+ background: #10b981;
624
+ border-radius: 50%;
625
+ animation: pulse 1.5s ease-in-out infinite;
626
+ }
627
+
628
+ @keyframes pulse {
629
+ 0%, 100% {
630
+ opacity: 1;
631
+ transform: scale(1);
632
+ }
633
+ 50% {
634
+ opacity: 0.5;
635
+ transform: scale(1.2);
636
+ }
637
+ }
638
+
639
+ @keyframes fadeIn {
640
+ from {
641
+ opacity: 0;
642
+ transform: translateY(-4px);
643
+ }
644
+ to {
645
+ opacity: 1;
646
+ transform: translateY(0);
647
+ }
648
+ }
649
+
650
+ /* Accessibility */
651
+ @media (prefers-reduced-motion: reduce) {
652
+ .pulse,
653
+ .status-indicator,
654
+ .playback-controls button,
655
+ .control-group input[type="range"]::-webkit-slider-thumb {
656
+ animation: none !important;
657
+ transition: none !important;
658
+ }
659
+ }
660
+ </style>