@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.
- package/README.md +209 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/tool-text-to-speech.js +2991 -0
- package/dist/tool-text-to-speech.js.map +1 -0
- package/index.ts +8 -0
- package/package.json +62 -0
- package/tool-text-to-speech.svelte +660 -0
|
@@ -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>
|