vidply 1.0.5 → 1.0.6

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,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
-
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
+
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
+ }