vidply 1.0.5 → 1.0.7

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.
@@ -1,728 +1,1803 @@
1
- /**
2
- * Transcript Manager Component
3
- * Manages transcript display and interaction
4
- */
5
-
6
- import { DOMUtils } from '../utils/DOMUtils.js';
7
- import { TimeUtils } from '../utils/TimeUtils.js';
8
- import { createIconElement } from '../icons/Icons.js';
9
- import { i18n } from '../i18n/i18n.js';
10
-
11
- export class TranscriptManager {
12
- constructor(player) {
13
- this.player = player;
14
- this.transcriptWindow = null;
15
- this.transcriptEntries = [];
16
- this.currentActiveEntry = null;
17
- this.isVisible = false;
18
-
19
- // Dragging state
20
- this.isDragging = false;
21
- this.dragOffsetX = 0;
22
- this.dragOffsetY = 0;
23
-
24
- // Store event handlers for cleanup
25
- this.handlers = {
26
- timeupdate: () => this.updateActiveEntry(),
27
- resize: null,
28
- mousemove: null,
29
- mouseup: null,
30
- touchmove: null,
31
- touchend: null,
32
- mousedown: null,
33
- touchstart: null,
34
- keydown: null
35
- };
36
-
37
- this.init();
38
- }
39
-
40
- init() {
41
- // Listen for time updates to highlight active transcript entry
42
- this.player.on('timeupdate', this.handlers.timeupdate);
43
-
44
- // Reposition transcript when entering/exiting fullscreen
45
- this.player.on('fullscreenchange', () => {
46
- if (this.isVisible) {
47
- // Add a small delay to ensure DOM has updated after fullscreen transition
48
- setTimeout(() => this.positionTranscript(), 100);
49
- }
50
- });
51
- }
52
-
53
- /**
54
- * Toggle transcript window visibility
55
- */
56
- toggleTranscript() {
57
- if (this.isVisible) {
58
- this.hideTranscript();
59
- } else {
60
- this.showTranscript();
61
- }
62
- }
63
-
64
- /**
65
- * Show transcript window
66
- */
67
- showTranscript() {
68
- if (this.transcriptWindow) {
69
- this.transcriptWindow.style.display = 'flex';
70
- this.isVisible = true;
71
- return;
72
- }
73
-
74
- // Create transcript window
75
- this.createTranscriptWindow();
76
- this.loadTranscriptData();
77
-
78
- // Show the window
79
- if (this.transcriptWindow) {
80
- this.transcriptWindow.style.display = 'flex';
81
- // Re-position after showing (in case window was resized while hidden)
82
- setTimeout(() => this.positionTranscript(), 0);
83
- }
84
- this.isVisible = true;
85
- }
86
-
87
- /**
88
- * Hide transcript window
89
- */
90
- hideTranscript() {
91
- if (this.transcriptWindow) {
92
- this.transcriptWindow.style.display = 'none';
93
- this.isVisible = false;
94
- }
95
- }
96
-
97
- /**
98
- * Create the transcript window UI
99
- */
100
- createTranscriptWindow() {
101
- this.transcriptWindow = DOMUtils.createElement('div', {
102
- className: `${this.player.options.classPrefix}-transcript-window`,
103
- attributes: {
104
- 'role': 'dialog',
105
- 'aria-label': 'Video Transcript',
106
- 'tabindex': '-1'
107
- }
108
- });
109
-
110
- // Header (draggable)
111
- this.transcriptHeader = DOMUtils.createElement('div', {
112
- className: `${this.player.options.classPrefix}-transcript-header`,
113
- attributes: {
114
- 'aria-label': 'Drag to reposition transcript. Use arrow keys to move, Home to reset position, Escape to close.',
115
- 'tabindex': '0'
116
- }
117
- });
118
-
119
- const title = DOMUtils.createElement('h3', {
120
- textContent: i18n.t('transcript.title')
121
- });
122
-
123
- const closeButton = DOMUtils.createElement('button', {
124
- className: `${this.player.options.classPrefix}-transcript-close`,
125
- attributes: {
126
- 'type': 'button',
127
- 'aria-label': i18n.t('transcript.close')
128
- }
129
- });
130
- closeButton.appendChild(createIconElement('close'));
131
- closeButton.addEventListener('click', () => this.hideTranscript());
132
-
133
- this.transcriptHeader.appendChild(title);
134
- this.transcriptHeader.appendChild(closeButton);
135
-
136
- // Content container
137
- this.transcriptContent = DOMUtils.createElement('div', {
138
- className: `${this.player.options.classPrefix}-transcript-content`
139
- });
140
-
141
- this.transcriptWindow.appendChild(this.transcriptHeader);
142
- this.transcriptWindow.appendChild(this.transcriptContent);
143
-
144
- // Append to player container
145
- this.player.container.appendChild(this.transcriptWindow);
146
-
147
- // Position it next to the video wrapper
148
- this.positionTranscript();
149
-
150
- // Setup drag functionality
151
- this.setupDragAndDrop();
152
-
153
- // Re-position on window resize (debounced)
154
- let resizeTimeout;
155
- this.handlers.resize = () => {
156
- clearTimeout(resizeTimeout);
157
- resizeTimeout = setTimeout(() => this.positionTranscript(), 100);
158
- };
159
- window.addEventListener('resize', this.handlers.resize);
160
- }
161
-
162
- /**
163
- * Position transcript window next to video
164
- */
165
- positionTranscript() {
166
- if (!this.transcriptWindow || !this.player.videoWrapper || !this.isVisible) return;
167
-
168
- const isMobile = window.innerWidth < 640;
169
- const videoRect = this.player.videoWrapper.getBoundingClientRect();
170
-
171
- // Check if player is in fullscreen mode
172
- const isFullscreen = this.player.state.fullscreen;
173
-
174
- if (isMobile && !isFullscreen) {
175
- // Mobile: Position underneath the video and controls as part of the layout
176
- this.transcriptWindow.style.position = 'relative';
177
- this.transcriptWindow.style.left = '0';
178
- this.transcriptWindow.style.right = '0';
179
- this.transcriptWindow.style.bottom = 'auto';
180
- this.transcriptWindow.style.top = 'auto';
181
- this.transcriptWindow.style.width = '100%';
182
- this.transcriptWindow.style.maxWidth = '100%';
183
- this.transcriptWindow.style.maxHeight = '400px';
184
- this.transcriptWindow.style.height = 'auto';
185
- this.transcriptWindow.style.borderRadius = '0';
186
- this.transcriptWindow.style.transform = 'none';
187
- this.transcriptWindow.style.border = 'none';
188
- this.transcriptWindow.style.borderTop = '1px solid var(--vidply-border-light)';
189
- this.transcriptWindow.style.boxShadow = 'none';
190
- // Disable dragging on mobile
191
- if (this.transcriptHeader) {
192
- this.transcriptHeader.style.cursor = 'default';
193
- }
194
-
195
- // Ensure transcript is at the container level for proper stacking
196
- if (this.transcriptWindow.parentNode !== this.player.container) {
197
- this.player.container.appendChild(this.transcriptWindow);
198
- }
199
- } else if (isFullscreen) {
200
- // In fullscreen: position in bottom right corner inside the video
201
- this.transcriptWindow.style.position = 'fixed';
202
- this.transcriptWindow.style.left = 'auto';
203
- this.transcriptWindow.style.right = '20px';
204
- this.transcriptWindow.style.bottom = '80px'; // Above controls
205
- this.transcriptWindow.style.top = 'auto';
206
- this.transcriptWindow.style.maxHeight = 'calc(100vh - 180px)'; // Leave space for controls
207
- this.transcriptWindow.style.height = 'auto';
208
- this.transcriptWindow.style.width = '400px';
209
- this.transcriptWindow.style.maxWidth = '400px';
210
- this.transcriptWindow.style.borderRadius = '8px';
211
- this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
212
- this.transcriptWindow.style.borderTop = '';
213
-
214
- // Move back to container for fullscreen
215
- if (this.transcriptWindow.parentNode !== this.player.container) {
216
- this.player.container.appendChild(this.transcriptWindow);
217
- }
218
- } else {
219
- // Desktop mode: position next to video
220
- this.transcriptWindow.style.position = 'absolute';
221
- this.transcriptWindow.style.left = `${videoRect.width + 8}px`;
222
- this.transcriptWindow.style.right = 'auto';
223
- this.transcriptWindow.style.bottom = 'auto';
224
- this.transcriptWindow.style.top = '0';
225
- this.transcriptWindow.style.height = `${videoRect.height}px`;
226
- this.transcriptWindow.style.maxHeight = 'none';
227
- this.transcriptWindow.style.width = '400px';
228
- this.transcriptWindow.style.maxWidth = '400px';
229
- this.transcriptWindow.style.borderRadius = '8px';
230
- this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
231
- this.transcriptWindow.style.borderTop = '';
232
- // Enable dragging on desktop
233
- if (this.transcriptHeader) {
234
- this.transcriptHeader.style.cursor = 'move';
235
- }
236
-
237
- // Move back to container for desktop
238
- if (this.transcriptWindow.parentNode !== this.player.container) {
239
- this.player.container.appendChild(this.transcriptWindow);
240
- }
241
- }
242
- }
243
-
244
- /**
245
- * Load transcript data from caption/subtitle tracks
246
- */
247
- loadTranscriptData() {
248
- this.transcriptEntries = [];
249
- this.transcriptContent.innerHTML = '';
250
-
251
- // Get caption/subtitle tracks
252
- const textTracks = Array.from(this.player.element.textTracks);
253
- const transcriptTrack = textTracks.find(
254
- track => track.kind === 'captions' || track.kind === 'subtitles'
255
- );
256
-
257
- if (!transcriptTrack) {
258
- this.showNoTranscriptMessage();
259
- return;
260
- }
261
-
262
- // Enable track to load cues
263
- if (transcriptTrack.mode === 'disabled') {
264
- transcriptTrack.mode = 'hidden';
265
- }
266
-
267
- if (!transcriptTrack.cues || transcriptTrack.cues.length === 0) {
268
- // Wait for cues to load
269
- const loadingMessage = DOMUtils.createElement('div', {
270
- className: `${this.player.options.classPrefix}-transcript-loading`,
271
- textContent: i18n.t('transcript.loading')
272
- });
273
- this.transcriptContent.appendChild(loadingMessage);
274
-
275
- const onLoad = () => {
276
- this.loadTranscriptData();
277
- };
278
-
279
- transcriptTrack.addEventListener('load', onLoad, { once: true });
280
-
281
- // Fallback timeout
282
- setTimeout(() => {
283
- if (transcriptTrack.cues && transcriptTrack.cues.length > 0) {
284
- this.loadTranscriptData();
285
- }
286
- }, 500);
287
-
288
- return;
289
- }
290
-
291
- // Build transcript from cues
292
- const cues = Array.from(transcriptTrack.cues);
293
- cues.forEach((cue, index) => {
294
- const entry = this.createTranscriptEntry(cue, index);
295
- this.transcriptEntries.push({
296
- element: entry,
297
- cue: cue,
298
- startTime: cue.startTime,
299
- endTime: cue.endTime
300
- });
301
- this.transcriptContent.appendChild(entry);
302
- });
303
- }
304
-
305
- /**
306
- * Create a single transcript entry element
307
- */
308
- createTranscriptEntry(cue, index) {
309
- const entry = DOMUtils.createElement('div', {
310
- className: `${this.player.options.classPrefix}-transcript-entry`,
311
- attributes: {
312
- 'data-start': String(cue.startTime),
313
- 'data-end': String(cue.endTime),
314
- 'role': 'button',
315
- 'tabindex': '0'
316
- }
317
- });
318
-
319
- const timestamp = DOMUtils.createElement('span', {
320
- className: `${this.player.options.classPrefix}-transcript-time`,
321
- textContent: TimeUtils.formatTime(cue.startTime)
322
- });
323
-
324
- const text = DOMUtils.createElement('span', {
325
- className: `${this.player.options.classPrefix}-transcript-text`,
326
- textContent: this.stripVTTFormatting(cue.text)
327
- });
328
-
329
- entry.appendChild(timestamp);
330
- entry.appendChild(text);
331
-
332
- // Click to seek
333
- const seekToTime = () => {
334
- this.player.seek(cue.startTime);
335
- if (this.player.state.paused) {
336
- this.player.play();
337
- }
338
- };
339
-
340
- entry.addEventListener('click', seekToTime);
341
- entry.addEventListener('keydown', (e) => {
342
- if (e.key === 'Enter' || e.key === ' ') {
343
- e.preventDefault();
344
- seekToTime();
345
- }
346
- });
347
-
348
- return entry;
349
- }
350
-
351
- /**
352
- * Strip VTT formatting tags from text
353
- */
354
- stripVTTFormatting(text) {
355
- // Remove VTT tags like <v Speaker>, <c>, etc.
356
- return text
357
- .replace(/<[^>]+>/g, '')
358
- .replace(/\n/g, ' ')
359
- .trim();
360
- }
361
-
362
- /**
363
- * Show message when no transcript is available
364
- */
365
- showNoTranscriptMessage() {
366
- const message = DOMUtils.createElement('div', {
367
- className: `${this.player.options.classPrefix}-transcript-empty`,
368
- textContent: i18n.t('transcript.noTranscript')
369
- });
370
- this.transcriptContent.appendChild(message);
371
- }
372
-
373
- /**
374
- * Update active transcript entry based on current time
375
- */
376
- updateActiveEntry() {
377
- if (!this.isVisible || this.transcriptEntries.length === 0) return;
378
-
379
- const currentTime = this.player.state.currentTime;
380
-
381
- // Find the entry that matches current time
382
- const activeEntry = this.transcriptEntries.find(
383
- entry => currentTime >= entry.startTime && currentTime < entry.endTime
384
- );
385
-
386
- if (activeEntry && activeEntry !== this.currentActiveEntry) {
387
- // Remove previous active class
388
- if (this.currentActiveEntry) {
389
- this.currentActiveEntry.element.classList.remove(
390
- `${this.player.options.classPrefix}-transcript-entry-active`
391
- );
392
- }
393
-
394
- // Add active class to current entry
395
- activeEntry.element.classList.add(
396
- `${this.player.options.classPrefix}-transcript-entry-active`
397
- );
398
-
399
- // Scroll to active entry
400
- this.scrollToEntry(activeEntry.element);
401
-
402
- this.currentActiveEntry = activeEntry;
403
- } else if (!activeEntry && this.currentActiveEntry) {
404
- // No active entry, remove active class
405
- this.currentActiveEntry.element.classList.remove(
406
- `${this.player.options.classPrefix}-transcript-entry-active`
407
- );
408
- this.currentActiveEntry = null;
409
- }
410
- }
411
-
412
- /**
413
- * Scroll transcript window to show active entry
414
- */
415
- scrollToEntry(entryElement) {
416
- if (!this.transcriptContent) return;
417
-
418
- const contentRect = this.transcriptContent.getBoundingClientRect();
419
- const entryRect = entryElement.getBoundingClientRect();
420
-
421
- // Check if entry is out of view
422
- if (entryRect.top < contentRect.top || entryRect.bottom > contentRect.bottom) {
423
- // Scroll to center the entry
424
- const scrollTop = entryElement.offsetTop - (this.transcriptContent.clientHeight / 2) + (entryElement.clientHeight / 2);
425
- this.transcriptContent.scrollTo({
426
- top: scrollTop,
427
- behavior: 'smooth'
428
- });
429
- }
430
- }
431
-
432
- /**
433
- * Setup drag and drop functionality
434
- */
435
- setupDragAndDrop() {
436
- if (!this.transcriptHeader || !this.transcriptWindow) return;
437
-
438
- // Create and store handler functions
439
- this.handlers.mousedown = (e) => {
440
- // Don't drag if clicking on close button
441
- if (e.target.closest(`.${this.player.options.classPrefix}-transcript-close`)) {
442
- return;
443
- }
444
-
445
- this.startDragging(e.clientX, e.clientY);
446
- e.preventDefault();
447
- };
448
-
449
- this.handlers.mousemove = (e) => {
450
- if (this.isDragging) {
451
- this.drag(e.clientX, e.clientY);
452
- }
453
- };
454
-
455
- this.handlers.mouseup = () => {
456
- if (this.isDragging) {
457
- this.stopDragging();
458
- }
459
- };
460
-
461
- this.handlers.touchstart = (e) => {
462
- if (e.target.closest(`.${this.player.options.classPrefix}-transcript-close`)) {
463
- return;
464
- }
465
-
466
- const isMobile = window.innerWidth < 640;
467
- const isFullscreen = this.player.state.fullscreen;
468
- const touch = e.touches[0];
469
-
470
- if (isMobile && !isFullscreen) {
471
- // Mobile (not fullscreen): No dragging/swiping, transcript is part of layout
472
- return;
473
- } else {
474
- // Desktop or fullscreen: Normal dragging
475
- this.startDragging(touch.clientX, touch.clientY);
476
- }
477
- };
478
-
479
- this.handlers.touchmove = (e) => {
480
- const isMobile = window.innerWidth < 640;
481
- const isFullscreen = this.player.state.fullscreen;
482
-
483
- if (isMobile && !isFullscreen) {
484
- // Mobile (not fullscreen): No dragging/swiping
485
- return;
486
- } else if (this.isDragging) {
487
- // Desktop or fullscreen: Normal drag
488
- const touch = e.touches[0];
489
- this.drag(touch.clientX, touch.clientY);
490
- e.preventDefault();
491
- }
492
- };
493
-
494
- this.handlers.touchend = () => {
495
- if (this.isDragging) {
496
- // Stop dragging
497
- this.stopDragging();
498
- }
499
- };
500
-
501
- this.handlers.keydown = (e) => {
502
- // Check if this is a navigation key
503
- if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'Escape'].includes(e.key)) {
504
- return;
505
- }
506
-
507
- // Prevent default behavior and stop event from bubbling to transcript entries
508
- e.preventDefault();
509
- e.stopPropagation();
510
-
511
- // Handle special keys first
512
- if (e.key === 'Home') {
513
- this.resetPosition();
514
- return;
515
- }
516
-
517
- if (e.key === 'Escape') {
518
- this.hideTranscript();
519
- return;
520
- }
521
-
522
- const step = e.shiftKey ? 50 : 10; // Larger steps with Shift key
523
-
524
- // Get current position
525
- let currentLeft = parseFloat(this.transcriptWindow.style.left) || 0;
526
- let currentTop = parseFloat(this.transcriptWindow.style.top) || 0;
527
-
528
- // If window is still centered with transform, convert to absolute position first
529
- const computedStyle = window.getComputedStyle(this.transcriptWindow);
530
- if (computedStyle.transform !== 'none') {
531
- const rect = this.transcriptWindow.getBoundingClientRect();
532
- currentLeft = rect.left;
533
- currentTop = rect.top;
534
- this.transcriptWindow.style.transform = 'none';
535
- this.transcriptWindow.style.left = `${currentLeft}px`;
536
- this.transcriptWindow.style.top = `${currentTop}px`;
537
- }
538
-
539
- // Calculate new position based on arrow key
540
- let newX = currentLeft;
541
- let newY = currentTop;
542
-
543
- switch(e.key) {
544
- case 'ArrowLeft':
545
- newX -= step;
546
- break;
547
- case 'ArrowRight':
548
- newX += step;
549
- break;
550
- case 'ArrowUp':
551
- newY -= step;
552
- break;
553
- case 'ArrowDown':
554
- newY += step;
555
- break;
556
- }
557
-
558
- // Set new position directly
559
- this.transcriptWindow.style.left = `${newX}px`;
560
- this.transcriptWindow.style.top = `${newY}px`;
561
- };
562
-
563
- // Add event listeners using stored handlers
564
- this.transcriptHeader.addEventListener('mousedown', this.handlers.mousedown);
565
- document.addEventListener('mousemove', this.handlers.mousemove);
566
- document.addEventListener('mouseup', this.handlers.mouseup);
567
-
568
- this.transcriptHeader.addEventListener('touchstart', this.handlers.touchstart);
569
- document.addEventListener('touchmove', this.handlers.touchmove);
570
- document.addEventListener('touchend', this.handlers.touchend);
571
-
572
- this.transcriptHeader.addEventListener('keydown', this.handlers.keydown);
573
- }
574
-
575
- /**
576
- * Start dragging
577
- */
578
- startDragging(clientX, clientY) {
579
- // Get current rendered position (this is where it actually appears on screen)
580
- const rect = this.transcriptWindow.getBoundingClientRect();
581
-
582
- // Get the parent container position (player container)
583
- const containerRect = this.player.container.getBoundingClientRect();
584
-
585
- // Calculate position RELATIVE to container (not viewport)
586
- const relativeLeft = rect.left - containerRect.left;
587
- const relativeTop = rect.top - containerRect.top;
588
-
589
- // If window is centered with transform, convert to absolute position
590
- const computedStyle = window.getComputedStyle(this.transcriptWindow);
591
- if (computedStyle.transform !== 'none') {
592
- // Remove transform and set position relative to container
593
- this.transcriptWindow.style.transform = 'none';
594
- this.transcriptWindow.style.left = `${relativeLeft}px`;
595
- this.transcriptWindow.style.top = `${relativeTop}px`;
596
- }
597
-
598
- // Calculate offset based on viewport coordinates (where user clicked)
599
- this.dragOffsetX = clientX - rect.left;
600
- this.dragOffsetY = clientY - rect.top;
601
-
602
- this.isDragging = true;
603
- this.transcriptWindow.classList.add(`${this.player.options.classPrefix}-transcript-dragging`);
604
- document.body.style.cursor = 'grabbing';
605
- document.body.style.userSelect = 'none';
606
- }
607
-
608
- /**
609
- * Perform drag
610
- */
611
- drag(clientX, clientY) {
612
- if (!this.isDragging) return;
613
-
614
- // Calculate new viewport position based on mouse position minus the offset
615
- const newViewportX = clientX - this.dragOffsetX;
616
- const newViewportY = clientY - this.dragOffsetY;
617
-
618
- // Convert to position relative to container
619
- const containerRect = this.player.container.getBoundingClientRect();
620
- const newX = newViewportX - containerRect.left;
621
- const newY = newViewportY - containerRect.top;
622
-
623
- // During drag, set position relative to container
624
- this.transcriptWindow.style.left = `${newX}px`;
625
- this.transcriptWindow.style.top = `${newY}px`;
626
- }
627
-
628
- /**
629
- * Stop dragging
630
- */
631
- stopDragging() {
632
- this.isDragging = false;
633
- this.transcriptWindow.classList.remove(`${this.player.options.classPrefix}-transcript-dragging`);
634
- document.body.style.cursor = '';
635
- document.body.style.userSelect = '';
636
- }
637
-
638
- /**
639
- * Set window position with boundary constraints
640
- */
641
- setPosition(x, y) {
642
- const rect = this.transcriptWindow.getBoundingClientRect();
643
-
644
- // Use document dimensions for fixed positioning
645
- const viewportWidth = document.documentElement.clientWidth;
646
- const viewportHeight = document.documentElement.clientHeight;
647
-
648
- // Very relaxed boundaries - allow window to go mostly off-screen
649
- // Just keep a small part visible so user can always drag it back
650
- const minVisible = 100; // Keep at least 100px visible
651
- const minX = -(rect.width - minVisible); // Can go way off-screen to the left
652
- const minY = -(rect.height - minVisible); // Can go way off-screen to the top
653
- const maxX = viewportWidth - minVisible; // Can go way off-screen to the right
654
- const maxY = viewportHeight - minVisible; // Can go way off-screen to the bottom
655
-
656
- // Clamp position to boundaries (very loose)
657
- x = Math.max(minX, Math.min(x, maxX));
658
- y = Math.max(minY, Math.min(y, maxY));
659
-
660
- this.transcriptWindow.style.left = `${x}px`;
661
- this.transcriptWindow.style.top = `${y}px`;
662
- this.transcriptWindow.style.transform = 'none';
663
- }
664
-
665
- /**
666
- * Reset position to center
667
- */
668
- resetPosition() {
669
- this.transcriptWindow.style.left = '50%';
670
- this.transcriptWindow.style.top = '50%';
671
- this.transcriptWindow.style.transform = 'translate(-50%, -50%)';
672
- }
673
-
674
- /**
675
- * Cleanup
676
- */
677
- destroy() {
678
- // Remove timeupdate listener from player
679
- if (this.handlers.timeupdate) {
680
- this.player.off('timeupdate', this.handlers.timeupdate);
681
- }
682
-
683
- // Remove drag event listeners
684
- if (this.transcriptHeader) {
685
- if (this.handlers.mousedown) {
686
- this.transcriptHeader.removeEventListener('mousedown', this.handlers.mousedown);
687
- }
688
- if (this.handlers.touchstart) {
689
- this.transcriptHeader.removeEventListener('touchstart', this.handlers.touchstart);
690
- }
691
- if (this.handlers.keydown) {
692
- this.transcriptHeader.removeEventListener('keydown', this.handlers.keydown);
693
- }
694
- }
695
-
696
- // Remove document-level listeners
697
- if (this.handlers.mousemove) {
698
- document.removeEventListener('mousemove', this.handlers.mousemove);
699
- }
700
- if (this.handlers.mouseup) {
701
- document.removeEventListener('mouseup', this.handlers.mouseup);
702
- }
703
- if (this.handlers.touchmove) {
704
- document.removeEventListener('touchmove', this.handlers.touchmove);
705
- }
706
- if (this.handlers.touchend) {
707
- document.removeEventListener('touchend', this.handlers.touchend);
708
- }
709
-
710
- // Remove window-level listeners
711
- if (this.handlers.resize) {
712
- window.removeEventListener('resize', this.handlers.resize);
713
- }
714
-
715
- // Clear handlers
716
- this.handlers = null;
717
-
718
- // Remove DOM element
719
- if (this.transcriptWindow && this.transcriptWindow.parentNode) {
720
- this.transcriptWindow.parentNode.removeChild(this.transcriptWindow);
721
- }
722
-
723
- this.transcriptWindow = null;
724
- this.transcriptHeader = null;
725
- this.transcriptContent = null;
726
- this.transcriptEntries = [];
727
- }
728
- }
1
+ /**
2
+ * Transcript Manager Component
3
+ * Manages transcript display and interaction
4
+ */
5
+
6
+ import { DOMUtils } from '../utils/DOMUtils.js';
7
+ import { TimeUtils } from '../utils/TimeUtils.js';
8
+ import { createIconElement } from '../icons/Icons.js';
9
+ import { i18n } from '../i18n/i18n.js';
10
+ import { StorageManager } from '../utils/StorageManager.js';
11
+
12
+ export class TranscriptManager {
13
+ constructor(player) {
14
+ this.player = player;
15
+ this.transcriptWindow = null;
16
+ this.transcriptEntries = [];
17
+ this.metadataCues = [];
18
+ this.currentActiveEntry = null;
19
+ this.isVisible = false;
20
+
21
+ // Storage manager
22
+ this.storage = new StorageManager('vidply');
23
+
24
+ // Dragging state
25
+ this.isDragging = false;
26
+ this.dragOffsetX = 0;
27
+ this.dragOffsetY = 0;
28
+
29
+ // Resizing state
30
+ this.isResizing = false;
31
+ this.resizeDirection = null;
32
+ this.resizeStartX = 0;
33
+ this.resizeStartY = 0;
34
+ this.resizeStartWidth = 0;
35
+ this.resizeStartHeight = 0;
36
+ this.resizeEnabled = false;
37
+
38
+ // Settings menu state
39
+ this.settingsMenuVisible = false;
40
+ this.settingsMenu = null;
41
+ this.settingsButton = null;
42
+ this.settingsMenuJustOpened = false;
43
+
44
+ // Keyboard drag mode
45
+ this.keyboardDragMode = false;
46
+
47
+ // Style dialog state
48
+ this.styleDialog = null;
49
+ this.styleDialogVisible = false;
50
+ this.styleDialogJustOpened = false;
51
+
52
+ // Load saved preferences from localStorage
53
+ const savedPreferences = this.storage.getTranscriptPreferences();
54
+
55
+ // Transcript styling options (with defaults, then player options, then saved preferences)
56
+ this.transcriptStyle = {
57
+ fontSize: savedPreferences?.fontSize || this.player.options.transcriptFontSize || '100%',
58
+ fontFamily: savedPreferences?.fontFamily || this.player.options.transcriptFontFamily || 'sans-serif',
59
+ color: savedPreferences?.color || this.player.options.transcriptColor || '#ffffff',
60
+ backgroundColor: savedPreferences?.backgroundColor || this.player.options.transcriptBackgroundColor || '#1e1e1e',
61
+ opacity: savedPreferences?.opacity ?? this.player.options.transcriptOpacity ?? 0.98
62
+ };
63
+
64
+ // Store event handlers for cleanup
65
+ this.handlers = {
66
+ timeupdate: () => this.updateActiveEntry(),
67
+ resize: null,
68
+ mousemove: null,
69
+ mouseup: null,
70
+ touchmove: null,
71
+ touchend: null,
72
+ mousedown: null,
73
+ touchstart: null,
74
+ keydown: null,
75
+ settingsClick: null,
76
+ settingsKeydown: null,
77
+ documentClick: null,
78
+ styleDialogKeydown: null
79
+ };
80
+
81
+ this.init();
82
+ }
83
+
84
+ init() {
85
+ // Listen for time updates to highlight active transcript entry
86
+ this.player.on('timeupdate', this.handlers.timeupdate);
87
+
88
+ // Reposition transcript when entering/exiting fullscreen
89
+ this.player.on('fullscreenchange', () => {
90
+ if (this.isVisible) {
91
+ // Add a small delay to ensure DOM has updated after fullscreen transition
92
+ setTimeout(() => this.positionTranscript(), 100);
93
+ }
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Toggle transcript window visibility
99
+ */
100
+ toggleTranscript() {
101
+ if (this.isVisible) {
102
+ this.hideTranscript();
103
+ } else {
104
+ this.showTranscript();
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Show transcript window
110
+ */
111
+ showTranscript() {
112
+ if (this.transcriptWindow) {
113
+ this.transcriptWindow.style.display = 'flex';
114
+ this.isVisible = true;
115
+
116
+ // Focus the settings button for keyboard accessibility
117
+ setTimeout(() => {
118
+ if (this.settingsButton) {
119
+ this.settingsButton.focus();
120
+ }
121
+ }, 150);
122
+ return;
123
+ }
124
+
125
+ // Create transcript window
126
+ this.createTranscriptWindow();
127
+ this.loadTranscriptData();
128
+
129
+ // Show the window
130
+ if (this.transcriptWindow) {
131
+ this.transcriptWindow.style.display = 'flex';
132
+ // Re-position after showing (in case window was resized while hidden)
133
+ setTimeout(() => this.positionTranscript(), 0);
134
+
135
+ // Focus the settings button for keyboard accessibility
136
+ setTimeout(() => {
137
+ if (this.settingsButton) {
138
+ this.settingsButton.focus();
139
+ }
140
+ }, 150);
141
+ }
142
+ this.isVisible = true;
143
+ }
144
+
145
+ /**
146
+ * Hide transcript window
147
+ */
148
+ hideTranscript() {
149
+ if (this.transcriptWindow) {
150
+ this.transcriptWindow.style.display = 'none';
151
+ this.isVisible = false;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Create the transcript window UI
157
+ */
158
+ createTranscriptWindow() {
159
+ this.transcriptWindow = DOMUtils.createElement('div', {
160
+ className: `${this.player.options.classPrefix}-transcript-window`,
161
+ attributes: {
162
+ 'role': 'dialog',
163
+ 'aria-label': 'Video Transcript',
164
+ 'tabindex': '-1'
165
+ }
166
+ });
167
+
168
+ // Header (draggable)
169
+ this.transcriptHeader = DOMUtils.createElement('div', {
170
+ className: `${this.player.options.classPrefix}-transcript-header`,
171
+ attributes: {
172
+ 'aria-label': 'Drag to reposition transcript. Use arrow keys to move, Home to reset position, Escape to close.',
173
+ 'tabindex': '0'
174
+ }
175
+ });
176
+
177
+ // Header left side (settings button + title)
178
+ this.headerLeft = DOMUtils.createElement('div', {
179
+ className: `${this.player.options.classPrefix}-transcript-header-left`
180
+ });
181
+
182
+ // Settings button
183
+ this.settingsButton = DOMUtils.createElement('button', {
184
+ className: `${this.player.options.classPrefix}-transcript-settings`,
185
+ attributes: {
186
+ 'type': 'button',
187
+ 'aria-label': i18n.t('transcript.settings'),
188
+ 'aria-expanded': 'false'
189
+ }
190
+ });
191
+ this.settingsButton.appendChild(createIconElement('settings'));
192
+ this.handlers.settingsClick = (e) => {
193
+ e.preventDefault();
194
+ e.stopPropagation();
195
+ if (this.settingsMenuVisible) {
196
+ this.hideSettingsMenu();
197
+ } else {
198
+ this.showSettingsMenu();
199
+ }
200
+ };
201
+ this.settingsButton.addEventListener('click', this.handlers.settingsClick);
202
+
203
+ // Keyboard handler for settings button
204
+ this.handlers.settingsKeydown = (e) => {
205
+ // D key to toggle keyboard drag mode
206
+ if (e.key === 'd' || e.key === 'D') {
207
+ e.preventDefault();
208
+ e.stopPropagation();
209
+ this.toggleKeyboardDragMode();
210
+ }
211
+ // R key to toggle resize mode
212
+ else if (e.key === 'r' || e.key === 'R') {
213
+ e.preventDefault();
214
+ e.stopPropagation();
215
+ this.toggleResizeMode();
216
+ }
217
+ // Escape to close menu if open
218
+ else if (e.key === 'Escape' && this.settingsMenuVisible) {
219
+ e.preventDefault();
220
+ e.stopPropagation();
221
+ this.hideSettingsMenu();
222
+ }
223
+ };
224
+ this.settingsButton.addEventListener('keydown', this.handlers.settingsKeydown);
225
+
226
+ const title = DOMUtils.createElement('h3', {
227
+ textContent: i18n.t('transcript.title')
228
+ });
229
+
230
+ this.headerLeft.appendChild(this.settingsButton);
231
+ this.headerLeft.appendChild(title);
232
+
233
+ const closeButton = DOMUtils.createElement('button', {
234
+ className: `${this.player.options.classPrefix}-transcript-close`,
235
+ attributes: {
236
+ 'type': 'button',
237
+ 'aria-label': i18n.t('transcript.close')
238
+ }
239
+ });
240
+ closeButton.appendChild(createIconElement('close'));
241
+ closeButton.addEventListener('click', () => this.hideTranscript());
242
+
243
+ this.transcriptHeader.appendChild(this.headerLeft);
244
+ this.transcriptHeader.appendChild(closeButton);
245
+
246
+ // Content container
247
+ this.transcriptContent = DOMUtils.createElement('div', {
248
+ className: `${this.player.options.classPrefix}-transcript-content`
249
+ });
250
+
251
+ this.transcriptWindow.appendChild(this.transcriptHeader);
252
+ this.transcriptWindow.appendChild(this.transcriptContent);
253
+
254
+ // Append to player container
255
+ this.player.container.appendChild(this.transcriptWindow);
256
+
257
+ // Position it next to the video wrapper
258
+ this.positionTranscript();
259
+
260
+ // Setup drag functionality
261
+ this.setupDragAndDrop();
262
+
263
+ // Setup document click handler to close settings menu and style dialog
264
+ // DON'T add it yet - it will be added when the menu is first opened
265
+ this.handlers.documentClick = (e) => {
266
+ // Ignore if menu was just opened (prevents immediate closing)
267
+ if (this.settingsMenuJustOpened) {
268
+ return;
269
+ }
270
+
271
+ // Ignore if style dialog was just opened (prevents immediate closing)
272
+ if (this.styleDialogJustOpened) {
273
+ return;
274
+ }
275
+
276
+ // Ignore clicks on the settings button itself
277
+ if (this.settingsButton && this.settingsButton.contains(e.target)) {
278
+ return;
279
+ }
280
+
281
+ // Ignore clicks on the settings menu items
282
+ if (this.settingsMenu && this.settingsMenu.contains(e.target)) {
283
+ return;
284
+ }
285
+
286
+ // Close settings menu if clicking outside
287
+ if (this.settingsMenuVisible) {
288
+ this.hideSettingsMenu();
289
+ }
290
+
291
+ // Close style dialog if clicking outside (but not on settings button)
292
+ if (this.styleDialogVisible && this.styleDialog &&
293
+ !this.styleDialog.contains(e.target)) {
294
+ this.hideStyleDialog();
295
+ }
296
+ };
297
+ // Store flag to track if handler has been added
298
+ this.documentClickHandlerAdded = false;
299
+
300
+ // Re-position on window resize (debounced)
301
+ let resizeTimeout;
302
+ this.handlers.resize = () => {
303
+ clearTimeout(resizeTimeout);
304
+ resizeTimeout = setTimeout(() => this.positionTranscript(), 100);
305
+ };
306
+ window.addEventListener('resize', this.handlers.resize);
307
+ }
308
+
309
+ /**
310
+ * Position transcript window next to video
311
+ */
312
+ positionTranscript() {
313
+ if (!this.transcriptWindow || !this.player.videoWrapper || !this.isVisible) return;
314
+
315
+ const isMobile = window.innerWidth < 640;
316
+ const videoRect = this.player.videoWrapper.getBoundingClientRect();
317
+
318
+ // Check if player is in fullscreen mode
319
+ const isFullscreen = this.player.state.fullscreen;
320
+
321
+ if (isMobile && !isFullscreen) {
322
+ // Mobile: Position underneath the video and controls as part of the layout
323
+ this.transcriptWindow.style.position = 'relative';
324
+ this.transcriptWindow.style.left = '0';
325
+ this.transcriptWindow.style.right = '0';
326
+ this.transcriptWindow.style.bottom = 'auto';
327
+ this.transcriptWindow.style.top = 'auto';
328
+ this.transcriptWindow.style.width = '100%';
329
+ this.transcriptWindow.style.maxWidth = '100%';
330
+ this.transcriptWindow.style.maxHeight = '400px';
331
+ this.transcriptWindow.style.height = 'auto';
332
+ this.transcriptWindow.style.borderRadius = '0';
333
+ this.transcriptWindow.style.transform = 'none';
334
+ this.transcriptWindow.style.border = 'none';
335
+ this.transcriptWindow.style.borderTop = '1px solid var(--vidply-border-light)';
336
+ this.transcriptWindow.style.boxShadow = 'none';
337
+ // Disable dragging on mobile
338
+ if (this.transcriptHeader) {
339
+ this.transcriptHeader.style.cursor = 'default';
340
+ }
341
+
342
+ // Ensure transcript is at the container level for proper stacking
343
+ if (this.transcriptWindow.parentNode !== this.player.container) {
344
+ this.player.container.appendChild(this.transcriptWindow);
345
+ }
346
+ } else if (isFullscreen) {
347
+ // In fullscreen: position in bottom right corner inside the video
348
+ this.transcriptWindow.style.position = 'fixed';
349
+ this.transcriptWindow.style.left = 'auto';
350
+ this.transcriptWindow.style.right = '20px';
351
+ this.transcriptWindow.style.bottom = '80px'; // Above controls
352
+ this.transcriptWindow.style.top = 'auto';
353
+ this.transcriptWindow.style.maxHeight = 'calc(100vh - 180px)'; // Leave space for controls
354
+ this.transcriptWindow.style.height = 'auto';
355
+ this.transcriptWindow.style.width = '400px';
356
+ this.transcriptWindow.style.maxWidth = '400px';
357
+ this.transcriptWindow.style.borderRadius = '8px';
358
+ this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
359
+ this.transcriptWindow.style.borderTop = '';
360
+
361
+ // Move back to container for fullscreen
362
+ if (this.transcriptWindow.parentNode !== this.player.container) {
363
+ this.player.container.appendChild(this.transcriptWindow);
364
+ }
365
+ } else {
366
+ // Desktop mode: position next to video
367
+ this.transcriptWindow.style.position = 'absolute';
368
+ this.transcriptWindow.style.left = `${videoRect.width + 8}px`;
369
+ this.transcriptWindow.style.right = 'auto';
370
+ this.transcriptWindow.style.bottom = 'auto';
371
+ this.transcriptWindow.style.top = '0';
372
+ this.transcriptWindow.style.height = `${videoRect.height}px`;
373
+ this.transcriptWindow.style.maxHeight = 'none';
374
+ this.transcriptWindow.style.width = '400px';
375
+ this.transcriptWindow.style.maxWidth = '400px';
376
+ this.transcriptWindow.style.borderRadius = '8px';
377
+ this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
378
+ this.transcriptWindow.style.borderTop = '';
379
+ // Enable dragging on desktop
380
+ if (this.transcriptHeader) {
381
+ this.transcriptHeader.style.cursor = 'move';
382
+ }
383
+
384
+ // Move back to container for desktop
385
+ if (this.transcriptWindow.parentNode !== this.player.container) {
386
+ this.player.container.appendChild(this.transcriptWindow);
387
+ }
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Load transcript data from caption/subtitle tracks
393
+ */
394
+ loadTranscriptData() {
395
+ this.transcriptEntries = [];
396
+ this.transcriptContent.innerHTML = '';
397
+
398
+ // Get all text tracks
399
+ const textTracks = Array.from(this.player.element.textTracks);
400
+
401
+ // Find different track types
402
+ const captionTrack = textTracks.find(
403
+ track => track.kind === 'captions' || track.kind === 'subtitles'
404
+ );
405
+ const descriptionTrack = textTracks.find(track => track.kind === 'descriptions');
406
+ const metadataTrack = textTracks.find(track => track.kind === 'metadata');
407
+
408
+ // We need at least one track type
409
+ if (!captionTrack && !descriptionTrack && !metadataTrack) {
410
+ this.showNoTranscriptMessage();
411
+ return;
412
+ }
413
+
414
+ // Enable all tracks to load cues
415
+ const tracksToLoad = [captionTrack, descriptionTrack, metadataTrack].filter(Boolean);
416
+ tracksToLoad.forEach(track => {
417
+ if (track.mode === 'disabled') {
418
+ track.mode = 'hidden';
419
+ }
420
+ });
421
+
422
+ // Check if any tracks are still loading
423
+ const needsLoading = tracksToLoad.some(track => !track.cues || track.cues.length === 0);
424
+
425
+ if (needsLoading) {
426
+ // Wait for cues to load
427
+ const loadingMessage = DOMUtils.createElement('div', {
428
+ className: `${this.player.options.classPrefix}-transcript-loading`,
429
+ textContent: i18n.t('transcript.loading')
430
+ });
431
+ this.transcriptContent.appendChild(loadingMessage);
432
+
433
+ let loaded = 0;
434
+ const onLoad = () => {
435
+ loaded++;
436
+ if (loaded >= tracksToLoad.length) {
437
+ this.loadTranscriptData();
438
+ }
439
+ };
440
+
441
+ tracksToLoad.forEach(track => {
442
+ track.addEventListener('load', onLoad, { once: true });
443
+ });
444
+
445
+ // Fallback timeout
446
+ setTimeout(() => {
447
+ this.loadTranscriptData();
448
+ }, 500);
449
+
450
+ return;
451
+ }
452
+
453
+ // Collect all cues from all tracks with their type
454
+ const allCues = [];
455
+
456
+ if (captionTrack && captionTrack.cues) {
457
+ Array.from(captionTrack.cues).forEach(cue => {
458
+ allCues.push({ cue, type: 'caption' });
459
+ });
460
+ }
461
+
462
+ if (descriptionTrack && descriptionTrack.cues) {
463
+ Array.from(descriptionTrack.cues).forEach(cue => {
464
+ allCues.push({ cue, type: 'description' });
465
+ });
466
+ }
467
+
468
+ // Store metadata separately for programmatic use (don't display in transcript)
469
+ if (metadataTrack && metadataTrack.cues) {
470
+ this.metadataCues = Array.from(metadataTrack.cues);
471
+ this.setupMetadataHandling();
472
+ }
473
+
474
+ // Sort all cues by start time
475
+ allCues.sort((a, b) => a.cue.startTime - b.cue.startTime);
476
+
477
+ // Build transcript from captions and descriptions only
478
+ allCues.forEach((item, index) => {
479
+ const entry = this.createTranscriptEntry(item.cue, index, item.type);
480
+ this.transcriptEntries.push({
481
+ element: entry,
482
+ cue: item.cue,
483
+ type: item.type,
484
+ startTime: item.cue.startTime,
485
+ endTime: item.cue.endTime
486
+ });
487
+ this.transcriptContent.appendChild(entry);
488
+ });
489
+
490
+ // Apply current styles to newly loaded entries
491
+ this.applyTranscriptStyles();
492
+ }
493
+
494
+ /**
495
+ * Setup metadata handling
496
+ * Metadata cues are not displayed but can be used programmatically
497
+ */
498
+ setupMetadataHandling() {
499
+ if (!this.metadataCues || this.metadataCues.length === 0) {
500
+ return;
501
+ }
502
+
503
+ // Listen for cuechange events on the metadata track to trigger custom actions
504
+ const textTracks = Array.from(this.player.element.textTracks);
505
+ const metadataTrack = textTracks.find(track => track.kind === 'metadata');
506
+
507
+ if (metadataTrack) {
508
+ metadataTrack.addEventListener('cuechange', () => {
509
+ const activeCues = Array.from(metadataTrack.activeCues || []);
510
+ activeCues.forEach(cue => {
511
+ this.handleMetadataCue(cue);
512
+ });
513
+ });
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Handle individual metadata cues
519
+ * Parses metadata text and emits events or triggers actions
520
+ */
521
+ handleMetadataCue(cue) {
522
+ const text = cue.text.trim();
523
+
524
+ // Emit a generic metadata event that developers can listen to
525
+ this.player.emit('metadata', {
526
+ time: cue.startTime,
527
+ endTime: cue.endTime,
528
+ text: text,
529
+ cue: cue
530
+ });
531
+
532
+ // Parse for specific commands (examples based on wwa_meta.vtt format)
533
+ if (text.includes('PAUSE')) {
534
+ // Emit pause suggestion event (don't auto-pause, let developer decide)
535
+ this.player.emit('metadata:pause', { time: cue.startTime, text: text });
536
+ }
537
+
538
+ // Parse for focus directives
539
+ const focusMatch = text.match(/FOCUS:([\w#-]+)/);
540
+ if (focusMatch) {
541
+ this.player.emit('metadata:focus', {
542
+ time: cue.startTime,
543
+ target: focusMatch[1],
544
+ text: text
545
+ });
546
+ }
547
+
548
+ // Parse for hashtag references
549
+ const hashtags = text.match(/#[\w-]+/g);
550
+ if (hashtags) {
551
+ this.player.emit('metadata:hashtags', {
552
+ time: cue.startTime,
553
+ hashtags: hashtags,
554
+ text: text
555
+ });
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Create a single transcript entry element
561
+ */
562
+ createTranscriptEntry(cue, index, type = 'caption') {
563
+ const entry = DOMUtils.createElement('div', {
564
+ className: `${this.player.options.classPrefix}-transcript-entry ${this.player.options.classPrefix}-transcript-${type}`,
565
+ attributes: {
566
+ 'data-start': String(cue.startTime),
567
+ 'data-end': String(cue.endTime),
568
+ 'data-type': type,
569
+ 'role': 'button',
570
+ 'tabindex': '0'
571
+ }
572
+ });
573
+
574
+ const timestamp = DOMUtils.createElement('span', {
575
+ className: `${this.player.options.classPrefix}-transcript-time`,
576
+ textContent: TimeUtils.formatTime(cue.startTime)
577
+ });
578
+
579
+ const text = DOMUtils.createElement('span', {
580
+ className: `${this.player.options.classPrefix}-transcript-text`,
581
+ textContent: this.stripVTTFormatting(cue.text)
582
+ });
583
+
584
+ entry.appendChild(timestamp);
585
+ entry.appendChild(text);
586
+
587
+ // Click to seek
588
+ const seekToTime = () => {
589
+ this.player.seek(cue.startTime);
590
+ if (this.player.state.paused) {
591
+ this.player.play();
592
+ }
593
+ };
594
+
595
+ entry.addEventListener('click', seekToTime);
596
+ entry.addEventListener('keydown', (e) => {
597
+ if (e.key === 'Enter' || e.key === ' ') {
598
+ e.preventDefault();
599
+ seekToTime();
600
+ }
601
+ });
602
+
603
+ return entry;
604
+ }
605
+
606
+ /**
607
+ * Strip VTT formatting tags from text
608
+ */
609
+ stripVTTFormatting(text) {
610
+ // Remove VTT tags like <v Speaker>, <c>, etc.
611
+ return text
612
+ .replace(/<[^>]+>/g, '')
613
+ .replace(/\n/g, ' ')
614
+ .trim();
615
+ }
616
+
617
+ /**
618
+ * Show message when no transcript is available
619
+ */
620
+ showNoTranscriptMessage() {
621
+ const message = DOMUtils.createElement('div', {
622
+ className: `${this.player.options.classPrefix}-transcript-empty`,
623
+ textContent: i18n.t('transcript.noTranscript')
624
+ });
625
+ this.transcriptContent.appendChild(message);
626
+ }
627
+
628
+ /**
629
+ * Update active transcript entry based on current time
630
+ */
631
+ updateActiveEntry() {
632
+ if (!this.isVisible || this.transcriptEntries.length === 0) return;
633
+
634
+ const currentTime = this.player.state.currentTime;
635
+
636
+ // Find the entry that matches current time
637
+ const activeEntry = this.transcriptEntries.find(
638
+ entry => currentTime >= entry.startTime && currentTime < entry.endTime
639
+ );
640
+
641
+ if (activeEntry && activeEntry !== this.currentActiveEntry) {
642
+ // Remove previous active class
643
+ if (this.currentActiveEntry) {
644
+ this.currentActiveEntry.element.classList.remove(
645
+ `${this.player.options.classPrefix}-transcript-entry-active`
646
+ );
647
+ }
648
+
649
+ // Add active class to current entry
650
+ activeEntry.element.classList.add(
651
+ `${this.player.options.classPrefix}-transcript-entry-active`
652
+ );
653
+
654
+ // Scroll to active entry
655
+ this.scrollToEntry(activeEntry.element);
656
+
657
+ this.currentActiveEntry = activeEntry;
658
+ } else if (!activeEntry && this.currentActiveEntry) {
659
+ // No active entry, remove active class
660
+ this.currentActiveEntry.element.classList.remove(
661
+ `${this.player.options.classPrefix}-transcript-entry-active`
662
+ );
663
+ this.currentActiveEntry = null;
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Scroll transcript window to show active entry
669
+ */
670
+ scrollToEntry(entryElement) {
671
+ if (!this.transcriptContent) return;
672
+
673
+ const contentRect = this.transcriptContent.getBoundingClientRect();
674
+ const entryRect = entryElement.getBoundingClientRect();
675
+
676
+ // Check if entry is out of view
677
+ if (entryRect.top < contentRect.top || entryRect.bottom > contentRect.bottom) {
678
+ // Scroll to center the entry
679
+ const scrollTop = entryElement.offsetTop - (this.transcriptContent.clientHeight / 2) + (entryElement.clientHeight / 2);
680
+ this.transcriptContent.scrollTo({
681
+ top: scrollTop,
682
+ behavior: 'smooth'
683
+ });
684
+ }
685
+ }
686
+
687
+ /**
688
+ * Setup drag and drop functionality
689
+ */
690
+ setupDragAndDrop() {
691
+ if (!this.transcriptHeader || !this.transcriptWindow) return;
692
+
693
+ // Create and store handler functions
694
+ this.handlers.mousedown = (e) => {
695
+ // Don't drag if clicking on close button
696
+ if (e.target.closest(`.${this.player.options.classPrefix}-transcript-close`)) {
697
+ return;
698
+ }
699
+
700
+ // Don't drag if clicking on settings button
701
+ if (e.target.closest(`.${this.player.options.classPrefix}-transcript-settings`)) {
702
+ return;
703
+ }
704
+
705
+ // Don't drag if clicking on settings menu
706
+ if (e.target.closest(`.${this.player.options.classPrefix}-transcript-settings-menu`)) {
707
+ return;
708
+ }
709
+
710
+ // Don't drag if clicking on style dialog
711
+ if (e.target.closest(`.${this.player.options.classPrefix}-transcript-style-dialog`)) {
712
+ return;
713
+ }
714
+
715
+ this.startDragging(e.clientX, e.clientY);
716
+ e.preventDefault();
717
+ };
718
+
719
+ this.handlers.mousemove = (e) => {
720
+ if (this.isDragging) {
721
+ this.drag(e.clientX, e.clientY);
722
+ }
723
+ };
724
+
725
+ this.handlers.mouseup = () => {
726
+ if (this.isDragging) {
727
+ this.stopDragging();
728
+ }
729
+ };
730
+
731
+ this.handlers.touchstart = (e) => {
732
+ if (e.target.closest(`.${this.player.options.classPrefix}-transcript-close`)) {
733
+ return;
734
+ }
735
+
736
+ // Don't drag if touching settings button
737
+ if (e.target.closest(`.${this.player.options.classPrefix}-transcript-settings`)) {
738
+ return;
739
+ }
740
+
741
+ // Don't drag if touching settings menu
742
+ if (e.target.closest(`.${this.player.options.classPrefix}-transcript-settings-menu`)) {
743
+ return;
744
+ }
745
+
746
+ // Don't drag if touching style dialog
747
+ if (e.target.closest(`.${this.player.options.classPrefix}-transcript-style-dialog`)) {
748
+ return;
749
+ }
750
+
751
+ const isMobile = window.innerWidth < 640;
752
+ const isFullscreen = this.player.state.fullscreen;
753
+ const touch = e.touches[0];
754
+
755
+ if (isMobile && !isFullscreen) {
756
+ // Mobile (not fullscreen): No dragging/swiping, transcript is part of layout
757
+ return;
758
+ } else {
759
+ // Desktop or fullscreen: Normal dragging
760
+ this.startDragging(touch.clientX, touch.clientY);
761
+ }
762
+ };
763
+
764
+ this.handlers.touchmove = (e) => {
765
+ const isMobile = window.innerWidth < 640;
766
+ const isFullscreen = this.player.state.fullscreen;
767
+
768
+ if (isMobile && !isFullscreen) {
769
+ // Mobile (not fullscreen): No dragging/swiping
770
+ return;
771
+ } else if (this.isDragging) {
772
+ // Desktop or fullscreen: Normal drag
773
+ const touch = e.touches[0];
774
+ this.drag(touch.clientX, touch.clientY);
775
+ e.preventDefault();
776
+ }
777
+ };
778
+
779
+ this.handlers.touchend = () => {
780
+ if (this.isDragging) {
781
+ // Stop dragging
782
+ this.stopDragging();
783
+ }
784
+ };
785
+
786
+ this.handlers.keydown = (e) => {
787
+ // Handle arrow keys only in keyboard drag mode
788
+ if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
789
+ if (!this.keyboardDragMode) {
790
+ // Not in drag mode, let other handlers deal with it
791
+ return;
792
+ }
793
+
794
+ // In drag mode - move the window
795
+ e.preventDefault();
796
+ e.stopPropagation();
797
+
798
+ const step = e.shiftKey ? 50 : 10; // Larger steps with Shift key
799
+
800
+ // Get current position
801
+ let currentLeft = parseFloat(this.transcriptWindow.style.left) || 0;
802
+ let currentTop = parseFloat(this.transcriptWindow.style.top) || 0;
803
+
804
+ // If window is still centered with transform, convert to absolute position first
805
+ const computedStyle = window.getComputedStyle(this.transcriptWindow);
806
+ if (computedStyle.transform !== 'none') {
807
+ const rect = this.transcriptWindow.getBoundingClientRect();
808
+ currentLeft = rect.left;
809
+ currentTop = rect.top;
810
+ this.transcriptWindow.style.transform = 'none';
811
+ this.transcriptWindow.style.left = `${currentLeft}px`;
812
+ this.transcriptWindow.style.top = `${currentTop}px`;
813
+ }
814
+
815
+ // Calculate new position based on arrow key
816
+ let newX = currentLeft;
817
+ let newY = currentTop;
818
+
819
+ switch(e.key) {
820
+ case 'ArrowLeft':
821
+ newX -= step;
822
+ break;
823
+ case 'ArrowRight':
824
+ newX += step;
825
+ break;
826
+ case 'ArrowUp':
827
+ newY -= step;
828
+ break;
829
+ case 'ArrowDown':
830
+ newY += step;
831
+ break;
832
+ }
833
+
834
+ // Set new position directly
835
+ this.transcriptWindow.style.left = `${newX}px`;
836
+ this.transcriptWindow.style.top = `${newY}px`;
837
+ return;
838
+ }
839
+
840
+ // Handle other special keys
841
+ if (e.key === 'Home') {
842
+ e.preventDefault();
843
+ e.stopPropagation();
844
+ this.resetPosition();
845
+ return;
846
+ }
847
+
848
+ if (e.key === 'Escape') {
849
+ e.preventDefault();
850
+ e.stopPropagation();
851
+ if (this.styleDialogVisible) {
852
+ // Close style dialog first
853
+ this.hideStyleDialog();
854
+ } else if (this.keyboardDragMode) {
855
+ // Exit drag mode
856
+ this.disableKeyboardDragMode();
857
+ } else if (this.settingsMenuVisible) {
858
+ // Close settings menu
859
+ this.hideSettingsMenu();
860
+ } else {
861
+ // Close transcript
862
+ this.hideTranscript();
863
+ }
864
+ return;
865
+ }
866
+ };
867
+
868
+ // Add event listeners using stored handlers
869
+ this.transcriptHeader.addEventListener('mousedown', this.handlers.mousedown);
870
+ document.addEventListener('mousemove', this.handlers.mousemove);
871
+ document.addEventListener('mouseup', this.handlers.mouseup);
872
+
873
+ this.transcriptHeader.addEventListener('touchstart', this.handlers.touchstart);
874
+ document.addEventListener('touchmove', this.handlers.touchmove);
875
+ document.addEventListener('touchend', this.handlers.touchend);
876
+
877
+ this.transcriptHeader.addEventListener('keydown', this.handlers.keydown);
878
+ }
879
+
880
+ /**
881
+ * Start dragging
882
+ */
883
+ startDragging(clientX, clientY) {
884
+ // Get current rendered position (this is where it actually appears on screen)
885
+ const rect = this.transcriptWindow.getBoundingClientRect();
886
+
887
+ // Get the parent container position (player container)
888
+ const containerRect = this.player.container.getBoundingClientRect();
889
+
890
+ // Calculate position RELATIVE to container (not viewport)
891
+ const relativeLeft = rect.left - containerRect.left;
892
+ const relativeTop = rect.top - containerRect.top;
893
+
894
+ // If window is centered with transform, convert to absolute position
895
+ const computedStyle = window.getComputedStyle(this.transcriptWindow);
896
+ if (computedStyle.transform !== 'none') {
897
+ // Remove transform and set position relative to container
898
+ this.transcriptWindow.style.transform = 'none';
899
+ this.transcriptWindow.style.left = `${relativeLeft}px`;
900
+ this.transcriptWindow.style.top = `${relativeTop}px`;
901
+ }
902
+
903
+ // Calculate offset based on viewport coordinates (where user clicked)
904
+ this.dragOffsetX = clientX - rect.left;
905
+ this.dragOffsetY = clientY - rect.top;
906
+
907
+ this.isDragging = true;
908
+ this.transcriptWindow.classList.add(`${this.player.options.classPrefix}-transcript-dragging`);
909
+ document.body.style.cursor = 'grabbing';
910
+ document.body.style.userSelect = 'none';
911
+ }
912
+
913
+ /**
914
+ * Perform drag
915
+ */
916
+ drag(clientX, clientY) {
917
+ if (!this.isDragging) return;
918
+
919
+ // Calculate new viewport position based on mouse position minus the offset
920
+ const newViewportX = clientX - this.dragOffsetX;
921
+ const newViewportY = clientY - this.dragOffsetY;
922
+
923
+ // Convert to position relative to container
924
+ const containerRect = this.player.container.getBoundingClientRect();
925
+ const newX = newViewportX - containerRect.left;
926
+ const newY = newViewportY - containerRect.top;
927
+
928
+ // During drag, set position relative to container
929
+ this.transcriptWindow.style.left = `${newX}px`;
930
+ this.transcriptWindow.style.top = `${newY}px`;
931
+ }
932
+
933
+ /**
934
+ * Stop dragging
935
+ */
936
+ stopDragging() {
937
+ this.isDragging = false;
938
+ this.transcriptWindow.classList.remove(`${this.player.options.classPrefix}-transcript-dragging`);
939
+ document.body.style.cursor = '';
940
+ document.body.style.userSelect = '';
941
+ }
942
+
943
+ /**
944
+ * Set window position with boundary constraints
945
+ */
946
+ setPosition(x, y) {
947
+ const rect = this.transcriptWindow.getBoundingClientRect();
948
+
949
+ // Use document dimensions for fixed positioning
950
+ const viewportWidth = document.documentElement.clientWidth;
951
+ const viewportHeight = document.documentElement.clientHeight;
952
+
953
+ // Very relaxed boundaries - allow window to go mostly off-screen
954
+ // Just keep a small part visible so user can always drag it back
955
+ const minVisible = 100; // Keep at least 100px visible
956
+ const minX = -(rect.width - minVisible); // Can go way off-screen to the left
957
+ const minY = -(rect.height - minVisible); // Can go way off-screen to the top
958
+ const maxX = viewportWidth - minVisible; // Can go way off-screen to the right
959
+ const maxY = viewportHeight - minVisible; // Can go way off-screen to the bottom
960
+
961
+ // Clamp position to boundaries (very loose)
962
+ x = Math.max(minX, Math.min(x, maxX));
963
+ y = Math.max(minY, Math.min(y, maxY));
964
+
965
+ this.transcriptWindow.style.left = `${x}px`;
966
+ this.transcriptWindow.style.top = `${y}px`;
967
+ this.transcriptWindow.style.transform = 'none';
968
+ }
969
+
970
+ /**
971
+ * Reset position to center
972
+ */
973
+ resetPosition() {
974
+ this.transcriptWindow.style.left = '50%';
975
+ this.transcriptWindow.style.top = '50%';
976
+ this.transcriptWindow.style.transform = 'translate(-50%, -50%)';
977
+ }
978
+
979
+ /**
980
+ * Toggle keyboard drag mode
981
+ */
982
+ toggleKeyboardDragMode() {
983
+ if (this.keyboardDragMode) {
984
+ this.disableKeyboardDragMode();
985
+ } else {
986
+ this.enableKeyboardDragMode();
987
+ }
988
+ }
989
+
990
+ /**
991
+ * Enable keyboard drag mode
992
+ */
993
+ enableKeyboardDragMode() {
994
+ this.keyboardDragMode = true;
995
+ this.transcriptWindow.classList.add(`${this.player.options.classPrefix}-transcript-keyboard-drag`);
996
+
997
+ // Update settings button aria label
998
+ if (this.settingsButton) {
999
+ this.settingsButton.setAttribute('aria-label', 'Keyboard drag mode active. Use arrow keys to move window. Press D or Escape to exit.');
1000
+ }
1001
+
1002
+ // Add visual indicator
1003
+ const indicator = DOMUtils.createElement('div', {
1004
+ className: `${this.player.options.classPrefix}-transcript-drag-indicator`,
1005
+ textContent: i18n.t('transcript.keyboardDragActive')
1006
+ });
1007
+ this.transcriptHeader.appendChild(indicator);
1008
+
1009
+ // Hide settings menu if open
1010
+ if (this.settingsMenuVisible) {
1011
+ this.hideSettingsMenu();
1012
+ }
1013
+
1014
+ // Focus the header for keyboard navigation
1015
+ this.transcriptHeader.focus();
1016
+ }
1017
+
1018
+ /**
1019
+ * Disable keyboard drag mode
1020
+ */
1021
+ disableKeyboardDragMode() {
1022
+ this.keyboardDragMode = false;
1023
+ this.transcriptWindow.classList.remove(`${this.player.options.classPrefix}-transcript-keyboard-drag`);
1024
+
1025
+ // Update settings button aria label
1026
+ if (this.settingsButton) {
1027
+ this.settingsButton.setAttribute('aria-label', 'Transcript settings. Press Enter to open menu, or D to enable drag mode');
1028
+ }
1029
+
1030
+ // Remove visual indicator
1031
+ const indicator = this.transcriptHeader.querySelector(`.${this.player.options.classPrefix}-transcript-drag-indicator`);
1032
+ if (indicator) {
1033
+ indicator.remove();
1034
+ }
1035
+
1036
+ // Focus back to settings button
1037
+ if (this.settingsButton) {
1038
+ this.settingsButton.focus();
1039
+ }
1040
+ }
1041
+
1042
+ /**
1043
+ * Toggle settings menu visibility
1044
+ */
1045
+ toggleSettingsMenu() {
1046
+ if (this.settingsMenuVisible) {
1047
+ this.hideSettingsMenu();
1048
+ } else {
1049
+ this.showSettingsMenu();
1050
+ }
1051
+ }
1052
+
1053
+ /**
1054
+ * Show settings menu
1055
+ */
1056
+ showSettingsMenu() {
1057
+ // Set flag to prevent immediate closing
1058
+ this.settingsMenuJustOpened = true;
1059
+ setTimeout(() => {
1060
+ this.settingsMenuJustOpened = false;
1061
+ }, 350);
1062
+
1063
+ // Add document click handler on FIRST menu open (not at window creation)
1064
+ if (!this.documentClickHandlerAdded) {
1065
+ setTimeout(() => {
1066
+ document.addEventListener('click', this.handlers.documentClick);
1067
+ this.documentClickHandlerAdded = true;
1068
+ }, 300);
1069
+ }
1070
+
1071
+ if (this.settingsMenu) {
1072
+ this.settingsMenu.style.display = 'block';
1073
+ this.settingsMenuVisible = true;
1074
+ return;
1075
+ }
1076
+ // Create settings menu
1077
+ this.settingsMenu = DOMUtils.createElement('div', {
1078
+ className: `${this.player.options.classPrefix}-transcript-settings-menu`
1079
+ });
1080
+
1081
+ // Keyboard drag option
1082
+ const keyboardDragOption = DOMUtils.createElement('button', {
1083
+ className: `${this.player.options.classPrefix}-transcript-settings-item`,
1084
+ attributes: {
1085
+ 'type': 'button',
1086
+ 'aria-label': i18n.t('transcript.keyboardDragMode')
1087
+ }
1088
+ });
1089
+ const keyboardIcon = createIconElement('move');
1090
+ const keyboardText = DOMUtils.createElement('span', {
1091
+ textContent: i18n.t('transcript.keyboardDragMode')
1092
+ });
1093
+ keyboardDragOption.appendChild(keyboardIcon);
1094
+ keyboardDragOption.appendChild(keyboardText);
1095
+ keyboardDragOption.addEventListener('click', () => {
1096
+ this.toggleKeyboardDragMode();
1097
+ this.hideSettingsMenu();
1098
+ });
1099
+
1100
+ // Style option
1101
+ const styleOption = DOMUtils.createElement('button', {
1102
+ className: `${this.player.options.classPrefix}-transcript-settings-item`,
1103
+ attributes: {
1104
+ 'type': 'button',
1105
+ 'aria-label': i18n.t('transcript.styleTranscript')
1106
+ }
1107
+ });
1108
+ const styleIcon = createIconElement('settings');
1109
+ const styleText = DOMUtils.createElement('span', {
1110
+ textContent: i18n.t('transcript.styleTranscript')
1111
+ });
1112
+ styleOption.appendChild(styleIcon);
1113
+ styleOption.appendChild(styleText);
1114
+ styleOption.addEventListener('click', (e) => {
1115
+ e.preventDefault();
1116
+ e.stopPropagation();
1117
+ this.hideSettingsMenu();
1118
+ // Delay to ensure menu is fully closed before opening dialog
1119
+ setTimeout(() => {
1120
+ this.showStyleDialog();
1121
+ }, 50);
1122
+ });
1123
+
1124
+ // Resize option
1125
+ const resizeOption = DOMUtils.createElement('button', {
1126
+ className: `${this.player.options.classPrefix}-transcript-settings-item`,
1127
+ attributes: {
1128
+ 'type': 'button',
1129
+ 'aria-label': i18n.t('transcript.resizeWindow')
1130
+ }
1131
+ });
1132
+ const resizeIcon = createIconElement('resize');
1133
+ const resizeText = DOMUtils.createElement('span', {
1134
+ textContent: i18n.t('transcript.resizeWindow')
1135
+ });
1136
+ resizeOption.appendChild(resizeIcon);
1137
+ resizeOption.appendChild(resizeText);
1138
+ resizeOption.addEventListener('click', () => {
1139
+ this.toggleResizeMode();
1140
+ this.hideSettingsMenu();
1141
+ });
1142
+
1143
+ // Close option
1144
+ const closeOption = DOMUtils.createElement('button', {
1145
+ className: `${this.player.options.classPrefix}-transcript-settings-item`,
1146
+ attributes: {
1147
+ 'type': 'button',
1148
+ 'aria-label': i18n.t('transcript.closeMenu')
1149
+ }
1150
+ });
1151
+ const closeIcon = createIconElement('close');
1152
+ const closeText = DOMUtils.createElement('span', {
1153
+ textContent: i18n.t('transcript.closeMenu')
1154
+ });
1155
+ closeOption.appendChild(closeIcon);
1156
+ closeOption.appendChild(closeText);
1157
+ closeOption.addEventListener('click', () => {
1158
+ this.hideSettingsMenu();
1159
+ });
1160
+
1161
+ this.settingsMenu.appendChild(keyboardDragOption);
1162
+ this.settingsMenu.appendChild(resizeOption);
1163
+ this.settingsMenu.appendChild(styleOption);
1164
+ this.settingsMenu.appendChild(closeOption);
1165
+
1166
+ // Append menu to header left container for proper positioning
1167
+ if (this.headerLeft) {
1168
+ this.headerLeft.appendChild(this.settingsMenu);
1169
+ } else {
1170
+ this.transcriptHeader.appendChild(this.settingsMenu);
1171
+ }
1172
+
1173
+ // Set the menu as visible and display it
1174
+ this.settingsMenuVisible = true;
1175
+ this.settingsMenu.style.display = 'block';
1176
+
1177
+ // Update aria-expanded
1178
+ if (this.settingsButton) {
1179
+ this.settingsButton.setAttribute('aria-expanded', 'true');
1180
+ }
1181
+
1182
+ // Focus first menu item
1183
+ setTimeout(() => {
1184
+ const firstItem = this.settingsMenu.querySelector(`.${this.player.options.classPrefix}-transcript-settings-item`);
1185
+ if (firstItem) {
1186
+ firstItem.focus();
1187
+ }
1188
+ }, 0);
1189
+ }
1190
+
1191
+ /**
1192
+ * Hide settings menu
1193
+ */
1194
+ hideSettingsMenu() {
1195
+ if (this.settingsMenu) {
1196
+ this.settingsMenu.style.display = 'none';
1197
+ this.settingsMenuVisible = false;
1198
+ this.settingsMenuJustOpened = false;
1199
+
1200
+ // Update aria-expanded
1201
+ if (this.settingsButton) {
1202
+ this.settingsButton.setAttribute('aria-expanded', 'false');
1203
+ // Return focus to settings button
1204
+ this.settingsButton.focus();
1205
+ }
1206
+ }
1207
+ }
1208
+
1209
+ /**
1210
+ * Enable move mode (gives visual feedback)
1211
+ */
1212
+ enableMoveMode() {
1213
+ // Add visual feedback for move mode
1214
+ this.transcriptWindow.classList.add(`${this.player.options.classPrefix}-transcript-move-mode`);
1215
+
1216
+ // Show tooltip about keyboard drag option
1217
+ const tooltip = DOMUtils.createElement('div', {
1218
+ className: `${this.player.options.classPrefix}-transcript-move-tooltip`,
1219
+ textContent: 'Drag with mouse or press D for keyboard drag mode'
1220
+ });
1221
+ this.transcriptHeader.appendChild(tooltip);
1222
+
1223
+ // Remove after 2 seconds
1224
+ setTimeout(() => {
1225
+ this.transcriptWindow.classList.remove(`${this.player.options.classPrefix}-transcript-move-mode`);
1226
+ if (tooltip.parentNode) {
1227
+ tooltip.remove();
1228
+ }
1229
+ }, 2000);
1230
+ }
1231
+
1232
+ /**
1233
+ * Toggle resize mode
1234
+ */
1235
+ toggleResizeMode() {
1236
+ this.resizeEnabled = !this.resizeEnabled;
1237
+
1238
+ if (this.resizeEnabled) {
1239
+ this.enableResizeHandles();
1240
+ } else {
1241
+ this.disableResizeHandles();
1242
+ }
1243
+ }
1244
+
1245
+ /**
1246
+ * Enable resize handles
1247
+ */
1248
+ enableResizeHandles() {
1249
+ if (!this.transcriptWindow) return;
1250
+
1251
+ // Add resize handles if they don't exist
1252
+ const directions = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
1253
+
1254
+ directions.forEach(direction => {
1255
+ const handle = DOMUtils.createElement('div', {
1256
+ className: `${this.player.options.classPrefix}-transcript-resize-handle ${this.player.options.classPrefix}-transcript-resize-${direction}`,
1257
+ attributes: {
1258
+ 'data-direction': direction
1259
+ }
1260
+ });
1261
+
1262
+ handle.addEventListener('mousedown', (e) => this.startResize(e, direction));
1263
+ handle.addEventListener('touchstart', (e) => this.startResize(e.touches[0], direction));
1264
+
1265
+ this.transcriptWindow.appendChild(handle);
1266
+ });
1267
+
1268
+ this.transcriptWindow.classList.add(`${this.player.options.classPrefix}-transcript-resizable`);
1269
+
1270
+ // Setup resize event handlers
1271
+ this.handlers.resizeMove = (e) => {
1272
+ if (this.isResizing) {
1273
+ this.performResize(e.clientX, e.clientY);
1274
+ }
1275
+ };
1276
+
1277
+ this.handlers.resizeEnd = () => {
1278
+ if (this.isResizing) {
1279
+ this.stopResize();
1280
+ }
1281
+ };
1282
+
1283
+ this.handlers.resizeTouchMove = (e) => {
1284
+ if (this.isResizing) {
1285
+ this.performResize(e.touches[0].clientX, e.touches[0].clientY);
1286
+ e.preventDefault();
1287
+ }
1288
+ };
1289
+
1290
+ document.addEventListener('mousemove', this.handlers.resizeMove);
1291
+ document.addEventListener('mouseup', this.handlers.resizeEnd);
1292
+ document.addEventListener('touchmove', this.handlers.resizeTouchMove);
1293
+ document.addEventListener('touchend', this.handlers.resizeEnd);
1294
+ }
1295
+
1296
+ /**
1297
+ * Disable resize handles
1298
+ */
1299
+ disableResizeHandles() {
1300
+ if (!this.transcriptWindow) return;
1301
+
1302
+ // Remove all resize handles
1303
+ const handles = this.transcriptWindow.querySelectorAll(`.${this.player.options.classPrefix}-transcript-resize-handle`);
1304
+ handles.forEach(handle => handle.remove());
1305
+
1306
+ this.transcriptWindow.classList.remove(`${this.player.options.classPrefix}-transcript-resizable`);
1307
+
1308
+ // Remove resize event handlers
1309
+ if (this.handlers.resizeMove) {
1310
+ document.removeEventListener('mousemove', this.handlers.resizeMove);
1311
+ }
1312
+ if (this.handlers.resizeEnd) {
1313
+ document.removeEventListener('mouseup', this.handlers.resizeEnd);
1314
+ }
1315
+ if (this.handlers.resizeTouchMove) {
1316
+ document.removeEventListener('touchmove', this.handlers.resizeTouchMove);
1317
+ }
1318
+ document.removeEventListener('touchend', this.handlers.resizeEnd);
1319
+ }
1320
+
1321
+ /**
1322
+ * Start resizing
1323
+ */
1324
+ startResize(e, direction) {
1325
+ e.stopPropagation();
1326
+ e.preventDefault();
1327
+
1328
+ this.isResizing = true;
1329
+ this.resizeDirection = direction;
1330
+ this.resizeStartX = e.clientX;
1331
+ this.resizeStartY = e.clientY;
1332
+
1333
+ const rect = this.transcriptWindow.getBoundingClientRect();
1334
+ this.resizeStartWidth = rect.width;
1335
+ this.resizeStartHeight = rect.height;
1336
+
1337
+ this.transcriptWindow.classList.add(`${this.player.options.classPrefix}-transcript-resizing`);
1338
+ document.body.style.cursor = this.getResizeCursor(direction);
1339
+ document.body.style.userSelect = 'none';
1340
+ }
1341
+
1342
+ /**
1343
+ * Perform resize
1344
+ */
1345
+ performResize(clientX, clientY) {
1346
+ if (!this.isResizing) return;
1347
+
1348
+ const deltaX = clientX - this.resizeStartX;
1349
+ const deltaY = clientY - this.resizeStartY;
1350
+
1351
+ let newWidth = this.resizeStartWidth;
1352
+ let newHeight = this.resizeStartHeight;
1353
+
1354
+ const direction = this.resizeDirection;
1355
+
1356
+ // Calculate new dimensions based on direction
1357
+ if (direction.includes('e')) {
1358
+ newWidth = this.resizeStartWidth + deltaX;
1359
+ }
1360
+ if (direction.includes('w')) {
1361
+ newWidth = this.resizeStartWidth - deltaX;
1362
+ }
1363
+ if (direction.includes('s')) {
1364
+ newHeight = this.resizeStartHeight + deltaY;
1365
+ }
1366
+ if (direction.includes('n')) {
1367
+ newHeight = this.resizeStartHeight - deltaY;
1368
+ }
1369
+
1370
+ // Apply minimum and maximum constraints
1371
+ const minWidth = 300;
1372
+ const minHeight = 200;
1373
+ const maxWidth = window.innerWidth - 40;
1374
+ const maxHeight = window.innerHeight - 40;
1375
+
1376
+ newWidth = Math.max(minWidth, Math.min(newWidth, maxWidth));
1377
+ newHeight = Math.max(minHeight, Math.min(newHeight, maxHeight));
1378
+
1379
+ // Apply new dimensions
1380
+ this.transcriptWindow.style.width = `${newWidth}px`;
1381
+ this.transcriptWindow.style.height = `${newHeight}px`;
1382
+ this.transcriptWindow.style.maxWidth = `${newWidth}px`;
1383
+ this.transcriptWindow.style.maxHeight = `${newHeight}px`;
1384
+
1385
+ // Adjust position if resizing from top or left
1386
+ if (direction.includes('w')) {
1387
+ const currentLeft = parseFloat(this.transcriptWindow.style.left) || 0;
1388
+ this.transcriptWindow.style.left = `${currentLeft + (this.resizeStartWidth - newWidth)}px`;
1389
+ }
1390
+ if (direction.includes('n')) {
1391
+ const currentTop = parseFloat(this.transcriptWindow.style.top) || 0;
1392
+ this.transcriptWindow.style.top = `${currentTop + (this.resizeStartHeight - newHeight)}px`;
1393
+ }
1394
+ }
1395
+
1396
+ /**
1397
+ * Stop resizing
1398
+ */
1399
+ stopResize() {
1400
+ this.isResizing = false;
1401
+ this.resizeDirection = null;
1402
+ this.transcriptWindow.classList.remove(`${this.player.options.classPrefix}-transcript-resizing`);
1403
+ document.body.style.cursor = '';
1404
+ document.body.style.userSelect = '';
1405
+ }
1406
+
1407
+ /**
1408
+ * Get cursor style for resize direction
1409
+ */
1410
+ getResizeCursor(direction) {
1411
+ const cursors = {
1412
+ 'n': 'ns-resize',
1413
+ 's': 'ns-resize',
1414
+ 'e': 'ew-resize',
1415
+ 'w': 'ew-resize',
1416
+ 'ne': 'nesw-resize',
1417
+ 'nw': 'nwse-resize',
1418
+ 'se': 'nwse-resize',
1419
+ 'sw': 'nesw-resize'
1420
+ };
1421
+ return cursors[direction] || 'default';
1422
+ }
1423
+
1424
+ /**
1425
+ * Show style dialog
1426
+ */
1427
+ showStyleDialog() {
1428
+ // If dialog already exists, just show it
1429
+ if (this.styleDialog) {
1430
+ this.styleDialog.style.display = 'block';
1431
+ this.styleDialogVisible = true;
1432
+
1433
+ // Set flag to prevent immediate closing from document click
1434
+ this.styleDialogJustOpened = true;
1435
+ setTimeout(() => {
1436
+ this.styleDialogJustOpened = false;
1437
+ }, 350);
1438
+
1439
+ // Focus first control
1440
+ setTimeout(() => {
1441
+ const firstSelect = this.styleDialog.querySelector('select, input');
1442
+ if (firstSelect) {
1443
+ firstSelect.focus();
1444
+ }
1445
+ }, 0);
1446
+ return;
1447
+ }
1448
+
1449
+ // Create style dialog
1450
+ this.styleDialog = DOMUtils.createElement('div', {
1451
+ className: `${this.player.options.classPrefix}-transcript-style-dialog`
1452
+ });
1453
+
1454
+ // Dialog title
1455
+ const title = DOMUtils.createElement('h4', {
1456
+ textContent: i18n.t('transcript.styleTitle'),
1457
+ className: `${this.player.options.classPrefix}-transcript-style-title`
1458
+ });
1459
+ this.styleDialog.appendChild(title);
1460
+
1461
+ // Font Size
1462
+ const fontSizeControl = this.createStyleSelectControl(
1463
+ i18n.t('captions.fontSize'),
1464
+ 'fontSize',
1465
+ [
1466
+ { label: i18n.t('fontSizes.small'), value: '87.5%' },
1467
+ { label: i18n.t('fontSizes.normal'), value: '100%' },
1468
+ { label: i18n.t('fontSizes.large'), value: '125%' },
1469
+ { label: i18n.t('fontSizes.xlarge'), value: '150%' }
1470
+ ]
1471
+ );
1472
+ this.styleDialog.appendChild(fontSizeControl);
1473
+
1474
+ // Font Family
1475
+ const fontFamilyControl = this.createStyleSelectControl(
1476
+ i18n.t('captions.fontFamily'),
1477
+ 'fontFamily',
1478
+ [
1479
+ { label: i18n.t('fontFamilies.sansSerif'), value: 'sans-serif' },
1480
+ { label: i18n.t('fontFamilies.serif'), value: 'serif' },
1481
+ { label: i18n.t('fontFamilies.monospace'), value: 'monospace' }
1482
+ ]
1483
+ );
1484
+ this.styleDialog.appendChild(fontFamilyControl);
1485
+
1486
+ // Text Color
1487
+ const colorControl = this.createStyleColorControl(i18n.t('captions.color'), 'color');
1488
+ this.styleDialog.appendChild(colorControl);
1489
+
1490
+ // Background Color
1491
+ const bgColorControl = this.createStyleColorControl(i18n.t('captions.backgroundColor'), 'backgroundColor');
1492
+ this.styleDialog.appendChild(bgColorControl);
1493
+
1494
+ // Opacity
1495
+ const opacityControl = this.createStyleOpacityControl(i18n.t('captions.opacity'), 'opacity');
1496
+ this.styleDialog.appendChild(opacityControl);
1497
+
1498
+ // Close button
1499
+ const closeBtn = DOMUtils.createElement('button', {
1500
+ className: `${this.player.options.classPrefix}-transcript-style-close`,
1501
+ textContent: i18n.t('settings.close'),
1502
+ attributes: {
1503
+ 'type': 'button'
1504
+ }
1505
+ });
1506
+ closeBtn.addEventListener('click', () => this.hideStyleDialog());
1507
+ this.styleDialog.appendChild(closeBtn);
1508
+
1509
+ // ESC key handler for style dialog
1510
+ this.handlers.styleDialogKeydown = (e) => {
1511
+ if (e.key === 'Escape') {
1512
+ e.preventDefault();
1513
+ e.stopPropagation();
1514
+ this.hideStyleDialog();
1515
+ }
1516
+ };
1517
+ this.styleDialog.addEventListener('keydown', this.handlers.styleDialogKeydown);
1518
+
1519
+ // Append to header left container (same as settings menu) for correct positioning
1520
+ if (this.headerLeft) {
1521
+ this.headerLeft.appendChild(this.styleDialog);
1522
+ } else {
1523
+ this.transcriptHeader.appendChild(this.styleDialog);
1524
+ }
1525
+
1526
+ // Apply current styles
1527
+ this.applyTranscriptStyles();
1528
+
1529
+ // Important: Set visible state and display before focusing
1530
+ this.styleDialogVisible = true;
1531
+ this.styleDialog.style.display = 'block';
1532
+
1533
+ // Set flag to prevent immediate closing from document click
1534
+ this.styleDialogJustOpened = true;
1535
+ setTimeout(() => {
1536
+ this.styleDialogJustOpened = false;
1537
+ }, 350);
1538
+
1539
+ // Focus first control for keyboard accessibility
1540
+ setTimeout(() => {
1541
+ const firstSelect = this.styleDialog.querySelector('select, input');
1542
+ if (firstSelect) {
1543
+ firstSelect.focus();
1544
+ }
1545
+ }, 0);
1546
+ }
1547
+
1548
+ /**
1549
+ * Hide style dialog
1550
+ */
1551
+ hideStyleDialog() {
1552
+ if (this.styleDialog) {
1553
+ this.styleDialog.style.display = 'none';
1554
+ this.styleDialogVisible = false;
1555
+
1556
+ // Return focus to settings button
1557
+ if (this.settingsButton) {
1558
+ this.settingsButton.focus();
1559
+ }
1560
+ }
1561
+ }
1562
+
1563
+ /**
1564
+ * Create style select control
1565
+ */
1566
+ createStyleSelectControl(label, property, options) {
1567
+ const group = DOMUtils.createElement('div', {
1568
+ className: `${this.player.options.classPrefix}-transcript-style-group`
1569
+ });
1570
+
1571
+ const labelEl = DOMUtils.createElement('label', {
1572
+ textContent: label
1573
+ });
1574
+ group.appendChild(labelEl);
1575
+
1576
+ const select = DOMUtils.createElement('select', {
1577
+ className: `${this.player.options.classPrefix}-transcript-style-select`
1578
+ });
1579
+
1580
+ options.forEach(opt => {
1581
+ const option = DOMUtils.createElement('option', {
1582
+ textContent: opt.label,
1583
+ attributes: {
1584
+ 'value': opt.value
1585
+ }
1586
+ });
1587
+ if (this.transcriptStyle[property] === opt.value) {
1588
+ option.selected = true;
1589
+ }
1590
+ select.appendChild(option);
1591
+ });
1592
+
1593
+ select.addEventListener('change', (e) => {
1594
+ this.transcriptStyle[property] = e.target.value;
1595
+ this.applyTranscriptStyles();
1596
+ this.savePreferences();
1597
+ });
1598
+
1599
+ group.appendChild(select);
1600
+ return group;
1601
+ }
1602
+
1603
+ /**
1604
+ * Create style color control
1605
+ */
1606
+ createStyleColorControl(label, property) {
1607
+ const group = DOMUtils.createElement('div', {
1608
+ className: `${this.player.options.classPrefix}-transcript-style-group`
1609
+ });
1610
+
1611
+ const labelEl = DOMUtils.createElement('label', {
1612
+ textContent: label
1613
+ });
1614
+ group.appendChild(labelEl);
1615
+
1616
+ const input = DOMUtils.createElement('input', {
1617
+ attributes: {
1618
+ 'type': 'color',
1619
+ 'value': this.transcriptStyle[property]
1620
+ },
1621
+ className: `${this.player.options.classPrefix}-transcript-style-color`
1622
+ });
1623
+
1624
+ input.addEventListener('input', (e) => {
1625
+ this.transcriptStyle[property] = e.target.value;
1626
+ this.applyTranscriptStyles();
1627
+ this.savePreferences();
1628
+ });
1629
+
1630
+ group.appendChild(input);
1631
+ return group;
1632
+ }
1633
+
1634
+ /**
1635
+ * Create style opacity control
1636
+ */
1637
+ createStyleOpacityControl(label, property) {
1638
+ const group = DOMUtils.createElement('div', {
1639
+ className: `${this.player.options.classPrefix}-transcript-style-group`
1640
+ });
1641
+
1642
+ const labelEl = DOMUtils.createElement('label', {
1643
+ textContent: label
1644
+ });
1645
+ group.appendChild(labelEl);
1646
+
1647
+ const valueDisplay = DOMUtils.createElement('span', {
1648
+ textContent: Math.round(this.transcriptStyle[property] * 100) + '%',
1649
+ className: `${this.player.options.classPrefix}-transcript-style-value`
1650
+ });
1651
+
1652
+ const input = DOMUtils.createElement('input', {
1653
+ attributes: {
1654
+ 'type': 'range',
1655
+ 'min': '0',
1656
+ 'max': '1',
1657
+ 'step': '0.1',
1658
+ 'value': String(this.transcriptStyle[property])
1659
+ },
1660
+ className: `${this.player.options.classPrefix}-transcript-style-range`
1661
+ });
1662
+
1663
+ input.addEventListener('input', (e) => {
1664
+ const value = parseFloat(e.target.value);
1665
+ this.transcriptStyle[property] = value;
1666
+ valueDisplay.textContent = Math.round(value * 100) + '%';
1667
+ this.applyTranscriptStyles();
1668
+ this.savePreferences();
1669
+ });
1670
+
1671
+ const inputContainer = DOMUtils.createElement('div', {
1672
+ className: `${this.player.options.classPrefix}-transcript-style-range-container`
1673
+ });
1674
+ inputContainer.appendChild(input);
1675
+ inputContainer.appendChild(valueDisplay);
1676
+
1677
+ group.appendChild(labelEl);
1678
+ group.appendChild(inputContainer);
1679
+ return group;
1680
+ }
1681
+
1682
+ /**
1683
+ * Save transcript preferences to localStorage
1684
+ */
1685
+ savePreferences() {
1686
+ this.storage.saveTranscriptPreferences(this.transcriptStyle);
1687
+ }
1688
+
1689
+ /**
1690
+ * Apply transcript styles
1691
+ */
1692
+ applyTranscriptStyles() {
1693
+ if (!this.transcriptWindow) return;
1694
+
1695
+ // Apply to transcript window background
1696
+ this.transcriptWindow.style.backgroundColor = this.transcriptStyle.backgroundColor;
1697
+ this.transcriptWindow.style.opacity = String(this.transcriptStyle.opacity);
1698
+
1699
+ // Apply to content area
1700
+ if (this.transcriptContent) {
1701
+ this.transcriptContent.style.fontSize = this.transcriptStyle.fontSize;
1702
+ this.transcriptContent.style.fontFamily = this.transcriptStyle.fontFamily;
1703
+ this.transcriptContent.style.color = this.transcriptStyle.color;
1704
+ }
1705
+
1706
+ // Apply to all text entries (important: override CSS defaults)
1707
+ const textEntries = this.transcriptWindow.querySelectorAll(`.${this.player.options.classPrefix}-transcript-text`);
1708
+ textEntries.forEach(entry => {
1709
+ entry.style.fontSize = this.transcriptStyle.fontSize;
1710
+ entry.style.fontFamily = this.transcriptStyle.fontFamily;
1711
+ entry.style.color = this.transcriptStyle.color;
1712
+ });
1713
+
1714
+ // Apply to timestamp entries as well
1715
+ const timeEntries = this.transcriptWindow.querySelectorAll(`.${this.player.options.classPrefix}-transcript-time`);
1716
+ timeEntries.forEach(entry => {
1717
+ entry.style.fontFamily = this.transcriptStyle.fontFamily;
1718
+ });
1719
+ }
1720
+
1721
+ /**
1722
+ * Cleanup
1723
+ */
1724
+ destroy() {
1725
+ // Disable modes if active
1726
+ if (this.resizeEnabled) {
1727
+ this.disableResizeHandles();
1728
+ }
1729
+ if (this.keyboardDragMode) {
1730
+ this.disableKeyboardDragMode();
1731
+ }
1732
+
1733
+ // Remove timeupdate listener from player
1734
+ if (this.handlers.timeupdate) {
1735
+ this.player.off('timeupdate', this.handlers.timeupdate);
1736
+ }
1737
+
1738
+ // Remove drag event listeners
1739
+ if (this.transcriptHeader) {
1740
+ if (this.handlers.mousedown) {
1741
+ this.transcriptHeader.removeEventListener('mousedown', this.handlers.mousedown);
1742
+ }
1743
+ if (this.handlers.touchstart) {
1744
+ this.transcriptHeader.removeEventListener('touchstart', this.handlers.touchstart);
1745
+ }
1746
+ if (this.handlers.keydown) {
1747
+ this.transcriptHeader.removeEventListener('keydown', this.handlers.keydown);
1748
+ }
1749
+ }
1750
+
1751
+ // Remove settings button event listeners
1752
+ if (this.settingsButton) {
1753
+ if (this.handlers.settingsClick) {
1754
+ this.settingsButton.removeEventListener('click', this.handlers.settingsClick);
1755
+ }
1756
+ if (this.handlers.settingsKeydown) {
1757
+ this.settingsButton.removeEventListener('keydown', this.handlers.settingsKeydown);
1758
+ }
1759
+ }
1760
+
1761
+ // Remove style dialog event listeners
1762
+ if (this.styleDialog && this.handlers.styleDialogKeydown) {
1763
+ this.styleDialog.removeEventListener('keydown', this.handlers.styleDialogKeydown);
1764
+ }
1765
+
1766
+ // Remove document-level listeners
1767
+ if (this.handlers.mousemove) {
1768
+ document.removeEventListener('mousemove', this.handlers.mousemove);
1769
+ }
1770
+ if (this.handlers.mouseup) {
1771
+ document.removeEventListener('mouseup', this.handlers.mouseup);
1772
+ }
1773
+ if (this.handlers.touchmove) {
1774
+ document.removeEventListener('touchmove', this.handlers.touchmove);
1775
+ }
1776
+ if (this.handlers.touchend) {
1777
+ document.removeEventListener('touchend', this.handlers.touchend);
1778
+ }
1779
+ if (this.handlers.documentClick) {
1780
+ document.removeEventListener('click', this.handlers.documentClick);
1781
+ }
1782
+
1783
+ // Remove window-level listeners
1784
+ if (this.handlers.resize) {
1785
+ window.removeEventListener('resize', this.handlers.resize);
1786
+ }
1787
+
1788
+ // Clear handlers
1789
+ this.handlers = null;
1790
+
1791
+ // Remove DOM element
1792
+ if (this.transcriptWindow && this.transcriptWindow.parentNode) {
1793
+ this.transcriptWindow.parentNode.removeChild(this.transcriptWindow);
1794
+ }
1795
+
1796
+ this.transcriptWindow = null;
1797
+ this.transcriptHeader = null;
1798
+ this.transcriptContent = null;
1799
+ this.transcriptEntries = [];
1800
+ this.settingsMenu = null;
1801
+ this.styleDialog = null;
1802
+ }
1803
+ }