vidply 1.0.8 → 1.0.10

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.
@@ -8,6 +8,7 @@ import { TimeUtils } from '../utils/TimeUtils.js';
8
8
  import { createIconElement } from '../icons/Icons.js';
9
9
  import { i18n } from '../i18n/i18n.js';
10
10
  import { StorageManager } from '../utils/StorageManager.js';
11
+ import { DraggableResizable } from '../utils/DraggableResizable.js';
11
12
 
12
13
  export class TranscriptManager {
13
14
  constructor(player) {
@@ -21,19 +22,8 @@ export class TranscriptManager {
21
22
  // Storage manager
22
23
  this.storage = new StorageManager('vidply');
23
24
 
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;
25
+ // Draggable/Resizable utility
26
+ this.draggableResizable = null;
37
27
 
38
28
  // Settings menu state
39
29
  this.settingsMenuVisible = false;
@@ -41,17 +31,31 @@ export class TranscriptManager {
41
31
  this.settingsButton = null;
42
32
  this.settingsMenuJustOpened = false;
43
33
 
44
- // Keyboard drag mode
45
- this.keyboardDragMode = false;
34
+ // Resize mode state
35
+ this.resizeOptionButton = null;
36
+ this.resizeOptionText = null;
37
+ this.resizeModeIndicator = null;
38
+ this.resizeModeIndicatorTimeout = null;
39
+ this.transcriptResizeHandles = [];
40
+ this.liveRegion = null;
46
41
 
47
42
  // Style dialog state
48
43
  this.styleDialog = null;
49
44
  this.styleDialogVisible = false;
50
45
  this.styleDialogJustOpened = false;
51
46
 
47
+ // Language selector state
48
+ this.languageSelector = null;
49
+ this.currentTranscriptLanguage = null;
50
+ this.availableTranscriptLanguages = [];
51
+ this.languageSelectorHandler = null;
52
+
52
53
  // Load saved preferences from localStorage
53
54
  const savedPreferences = this.storage.getTranscriptPreferences();
54
55
 
56
+ // Autoscroll state (default: true)
57
+ this.autoscrollEnabled = savedPreferences?.autoscroll !== undefined ? savedPreferences.autoscroll : true;
58
+
55
59
  // Transcript styling options (with defaults, then player options, then saved preferences)
56
60
  this.transcriptStyle = {
57
61
  fontSize: savedPreferences?.fontSize || this.player.options.transcriptFontSize || '100%',
@@ -65,31 +69,33 @@ export class TranscriptManager {
65
69
  this.handlers = {
66
70
  timeupdate: () => this.updateActiveEntry(),
67
71
  resize: null,
68
- mousemove: null,
69
- mouseup: null,
70
- touchmove: null,
71
- touchend: null,
72
- mousedown: null,
73
- touchstart: null,
74
- keydown: null,
75
72
  settingsClick: null,
76
73
  settingsKeydown: null,
77
74
  documentClick: null,
78
75
  styleDialogKeydown: null
79
76
  };
80
77
 
78
+ // Timeout management (for cleanup)
79
+ this.timeouts = new Set();
80
+
81
81
  this.init();
82
82
  }
83
83
 
84
84
  init() {
85
+ // Set up metadata handling immediately (independent of transcript display)
86
+ this.setupMetadataHandlingOnLoad();
87
+
85
88
  // Listen for time updates to highlight active transcript entry
86
89
  this.player.on('timeupdate', this.handlers.timeupdate);
87
90
 
88
91
  // Reposition transcript when entering/exiting fullscreen
89
92
  this.player.on('fullscreenchange', () => {
90
93
  if (this.isVisible) {
91
- // Add a small delay to ensure DOM has updated after fullscreen transition
92
- setTimeout(() => this.positionTranscript(), 100);
94
+ // Only auto-position if user hasn't manually positioned it
95
+ if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
96
+ // Add a small delay to ensure DOM has updated after fullscreen transition
97
+ this.setManagedTimeout(() => this.positionTranscript(), 100);
98
+ }
93
99
  }
94
100
  });
95
101
  }
@@ -112,11 +118,15 @@ export class TranscriptManager {
112
118
  if (this.transcriptWindow) {
113
119
  this.transcriptWindow.style.display = 'flex';
114
120
  this.isVisible = true;
121
+
122
+ if (this.player.controlBar && typeof this.player.controlBar.updateTranscriptButton === 'function') {
123
+ this.player.controlBar.updateTranscriptButton();
124
+ }
115
125
 
116
- // Focus the settings button for keyboard accessibility
117
- setTimeout(() => {
118
- if (this.settingsButton) {
119
- this.settingsButton.focus();
126
+ // Focus the header for keyboard accessibility
127
+ this.setManagedTimeout(() => {
128
+ if (this.transcriptHeader) {
129
+ this.transcriptHeader.focus();
120
130
  }
121
131
  }, 150);
122
132
  return;
@@ -129,13 +139,17 @@ export class TranscriptManager {
129
139
  // Show the window
130
140
  if (this.transcriptWindow) {
131
141
  this.transcriptWindow.style.display = 'flex';
132
- // Re-position after showing (in case window was resized while hidden)
133
- setTimeout(() => this.positionTranscript(), 0);
134
142
 
135
- // Focus the settings button for keyboard accessibility
136
- setTimeout(() => {
137
- if (this.settingsButton) {
138
- this.settingsButton.focus();
143
+ // Only auto-position if user hasn't manually positioned it
144
+ // This prevents overwriting saved positions from localStorage
145
+ if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
146
+ this.setManagedTimeout(() => this.positionTranscript(), 0);
147
+ }
148
+
149
+ // Focus the header for keyboard accessibility
150
+ this.setManagedTimeout(() => {
151
+ if (this.transcriptHeader) {
152
+ this.transcriptHeader.focus();
139
153
  }
140
154
  }, 150);
141
155
  }
@@ -145,11 +159,29 @@ export class TranscriptManager {
145
159
  /**
146
160
  * Hide transcript window
147
161
  */
148
- hideTranscript() {
162
+ hideTranscript({ focusButton = false } = {}) {
149
163
  if (this.transcriptWindow) {
150
164
  this.transcriptWindow.style.display = 'none';
151
165
  this.isVisible = false;
152
166
  }
167
+ if (this.draggableResizable && this.draggableResizable.pointerResizeMode) {
168
+ this.draggableResizable.disablePointerResizeMode();
169
+ this.updateResizeOptionState();
170
+ }
171
+ this.hideResizeModeIndicator();
172
+ this.announceLive('');
173
+
174
+ // Update transcript button state in control bar
175
+ if (this.player.controlBar && typeof this.player.controlBar.updateTranscriptButton === 'function') {
176
+ this.player.controlBar.updateTranscriptButton();
177
+ }
178
+
179
+ if (focusButton) {
180
+ const transcriptButton = this.player.controlBar?.controls?.transcript;
181
+ if (transcriptButton && typeof transcriptButton.focus === 'function') {
182
+ transcriptButton.focus();
183
+ }
184
+ }
153
185
  }
154
186
 
155
187
  /**
@@ -169,7 +201,6 @@ export class TranscriptManager {
169
201
  this.transcriptHeader = DOMUtils.createElement('div', {
170
202
  className: `${this.player.options.classPrefix}-transcript-header`,
171
203
  attributes: {
172
- 'aria-label': 'Drag to reposition transcript. Use arrow keys to move, Home to reset position, Escape to close.',
173
204
  'tabindex': '0'
174
205
  }
175
206
  });
@@ -184,7 +215,7 @@ export class TranscriptManager {
184
215
  className: `${this.player.options.classPrefix}-transcript-settings`,
185
216
  attributes: {
186
217
  'type': 'button',
187
- 'aria-label': i18n.t('transcript.settings'),
218
+ 'aria-label': i18n.t('transcript.settingsMenu'),
188
219
  'aria-expanded': 'false'
189
220
  }
190
221
  });
@@ -224,11 +255,52 @@ export class TranscriptManager {
224
255
  this.settingsButton.addEventListener('keydown', this.handlers.settingsKeydown);
225
256
 
226
257
  const title = DOMUtils.createElement('h3', {
227
- textContent: i18n.t('transcript.title')
258
+ textContent: `${i18n.t('transcript.title')}. ${i18n.t('transcript.dragResizePrompt')}`
259
+ });
260
+
261
+ // Autoscroll checkbox
262
+ const autoscrollLabel = DOMUtils.createElement('label', {
263
+ className: `${this.player.options.classPrefix}-transcript-autoscroll-label`,
264
+ attributes: {
265
+ 'title': i18n.t('transcript.autoscroll')
266
+ }
267
+ });
268
+
269
+ this.autoscrollCheckbox = DOMUtils.createElement('input', {
270
+ attributes: {
271
+ 'type': 'checkbox',
272
+ 'checked': this.autoscrollEnabled,
273
+ 'aria-label': i18n.t('transcript.autoscroll')
274
+ }
275
+ });
276
+
277
+ const autoscrollText = DOMUtils.createElement('span', {
278
+ textContent: i18n.t('transcript.autoscroll'),
279
+ className: `${this.player.options.classPrefix}-transcript-autoscroll-text`
280
+ });
281
+
282
+ autoscrollLabel.appendChild(this.autoscrollCheckbox);
283
+ autoscrollLabel.appendChild(autoscrollText);
284
+
285
+ // Handle autoscroll checkbox change
286
+ this.autoscrollCheckbox.addEventListener('change', (e) => {
287
+ this.autoscrollEnabled = e.target.checked;
288
+ this.saveAutoscrollPreference();
228
289
  });
229
290
 
291
+ this.transcriptHeader.appendChild(title);
230
292
  this.headerLeft.appendChild(this.settingsButton);
231
- this.headerLeft.appendChild(title);
293
+ this.headerLeft.appendChild(autoscrollLabel);
294
+
295
+ // Language selector (will be populated after tracks are loaded)
296
+ this.languageSelector = DOMUtils.createElement('select', {
297
+ className: `${this.player.options.classPrefix}-transcript-language-select`,
298
+ attributes: {
299
+ 'aria-label': i18n.t('settings.language') || 'Language',
300
+ 'style': 'display: none;' // Hidden until we detect multiple languages
301
+ }
302
+ });
303
+ this.headerLeft.appendChild(this.languageSelector);
232
304
 
233
305
  const closeButton = DOMUtils.createElement('button', {
234
306
  className: `${this.player.options.classPrefix}-transcript-close`,
@@ -238,7 +310,7 @@ export class TranscriptManager {
238
310
  }
239
311
  });
240
312
  closeButton.appendChild(createIconElement('close'));
241
- closeButton.addEventListener('click', () => this.hideTranscript());
313
+ closeButton.addEventListener('click', () => this.hideTranscript({ focusButton: true }));
242
314
 
243
315
  this.transcriptHeader.appendChild(this.headerLeft);
244
316
  this.transcriptHeader.appendChild(closeButton);
@@ -251,15 +323,30 @@ export class TranscriptManager {
251
323
  this.transcriptWindow.appendChild(this.transcriptHeader);
252
324
  this.transcriptWindow.appendChild(this.transcriptContent);
253
325
 
326
+ this.createResizeHandles();
327
+
328
+ // Live region for announcements (screen reader feedback)
329
+ this.liveRegion = DOMUtils.createElement('div', {
330
+ className: 'vidply-sr-only',
331
+ attributes: {
332
+ 'aria-live': 'polite',
333
+ 'aria-atomic': 'true'
334
+ }
335
+ });
336
+ this.transcriptWindow.appendChild(this.liveRegion);
337
+
254
338
  // Append to player container
255
339
  this.player.container.appendChild(this.transcriptWindow);
256
340
 
257
- // Position it next to the video wrapper
258
- this.positionTranscript();
259
-
260
- // Setup drag functionality
341
+ // Setup drag functionality FIRST (this will restore saved position if it exists)
261
342
  this.setupDragAndDrop();
262
343
 
344
+ // Then position it next to the video wrapper ONLY if user hasn't manually positioned it
345
+ // This ensures we don't overwrite saved positions from localStorage
346
+ if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
347
+ this.positionTranscript();
348
+ }
349
+
263
350
  // Setup document click handler to close settings menu and style dialog
264
351
  // DON'T add it yet - it will be added when the menu is first opened
265
352
  this.handlers.documentClick = (e) => {
@@ -297,21 +384,53 @@ export class TranscriptManager {
297
384
  // Store flag to track if handler has been added
298
385
  this.documentClickHandlerAdded = false;
299
386
 
300
- // Re-position on window resize (debounced)
387
+ // Re-position on window resize (debounced) - but only if not manually positioned
301
388
  let resizeTimeout;
302
389
  this.handlers.resize = () => {
303
- clearTimeout(resizeTimeout);
304
- resizeTimeout = setTimeout(() => this.positionTranscript(), 100);
390
+ if (resizeTimeout) {
391
+ this.clearManagedTimeout(resizeTimeout);
392
+ }
393
+ resizeTimeout = this.setManagedTimeout(() => {
394
+ // Only auto-position if user hasn't manually moved it
395
+ if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
396
+ this.positionTranscript();
397
+ }
398
+ }, 100);
305
399
  };
306
400
  window.addEventListener('resize', this.handlers.resize);
307
401
  }
308
402
 
403
+ createResizeHandles() {
404
+ if (!this.transcriptWindow) return;
405
+
406
+ const directions = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
407
+ this.transcriptResizeHandles = directions.map(direction => {
408
+ const handle = DOMUtils.createElement('div', {
409
+ className: `${this.player.options.classPrefix}-transcript-resize-handle ${this.player.options.classPrefix}-transcript-resize-${direction}`,
410
+ attributes: {
411
+ 'data-direction': direction,
412
+ 'data-vidply-managed-resize': 'true',
413
+ 'aria-hidden': 'true'
414
+ }
415
+ });
416
+
417
+ handle.style.display = 'none';
418
+ this.transcriptWindow.appendChild(handle);
419
+ return handle;
420
+ });
421
+ }
422
+
309
423
  /**
310
424
  * Position transcript window next to video
311
425
  */
312
426
  positionTranscript() {
313
427
  if (!this.transcriptWindow || !this.player.videoWrapper || !this.isVisible) return;
314
428
 
429
+ // Don't auto-position if user has manually positioned it
430
+ if (this.draggableResizable && this.draggableResizable.manuallyPositioned) {
431
+ return;
432
+ }
433
+
315
434
  const isMobile = window.innerWidth < 640;
316
435
  const videoRect = this.player.videoWrapper.getBoundingClientRect();
317
436
 
@@ -352,8 +471,12 @@ export class TranscriptManager {
352
471
  this.transcriptWindow.style.top = 'auto';
353
472
  this.transcriptWindow.style.maxHeight = 'calc(100vh - 180px)'; // Leave space for controls
354
473
  this.transcriptWindow.style.height = 'auto';
355
- this.transcriptWindow.style.width = '400px';
356
- this.transcriptWindow.style.maxWidth = '400px';
474
+ const fullscreenMinWidth = 260;
475
+ const fullscreenAvailable = Math.max(fullscreenMinWidth, window.innerWidth - 40);
476
+ const fullscreenDesired = parseFloat(this.transcriptWindow.style.width) || 400;
477
+ const fullscreenWidth = Math.max(fullscreenMinWidth, Math.min(fullscreenDesired, fullscreenAvailable));
478
+ this.transcriptWindow.style.width = `${fullscreenWidth}px`;
479
+ this.transcriptWindow.style.maxWidth = 'none';
357
480
  this.transcriptWindow.style.borderRadius = '8px';
358
481
  this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
359
482
  this.transcriptWindow.style.borderTop = '';
@@ -363,16 +486,35 @@ export class TranscriptManager {
363
486
  this.player.container.appendChild(this.transcriptWindow);
364
487
  }
365
488
  } else {
366
- // Desktop mode: position next to video
489
+ // Desktop mode: position in right side of viewport
490
+ const transcriptWidth = parseFloat(this.transcriptWindow.style.width) || 400;
491
+ const padding = 20;
492
+ const minWidth = 260;
493
+ const containerRect = this.player.container.getBoundingClientRect();
494
+
495
+ const ensureContainerPositioned = () => {
496
+ const computed = window.getComputedStyle(this.player.container);
497
+ if (computed.position === 'static') {
498
+ this.player.container.style.position = 'relative';
499
+ }
500
+ };
501
+
502
+ ensureContainerPositioned();
503
+
504
+ const left = (videoRect.right - containerRect.left) + padding;
505
+ const availableWidth = window.innerWidth - videoRect.right - padding;
506
+ const appliedWidth = Math.max(minWidth, Math.min(transcriptWidth, availableWidth));
507
+ const appliedHeight = videoRect.height;
508
+
367
509
  this.transcriptWindow.style.position = 'absolute';
368
- this.transcriptWindow.style.left = `${videoRect.width + 8}px`;
510
+ this.transcriptWindow.style.left = `${left}px`;
369
511
  this.transcriptWindow.style.right = 'auto';
370
512
  this.transcriptWindow.style.bottom = 'auto';
371
513
  this.transcriptWindow.style.top = '0';
372
- this.transcriptWindow.style.height = `${videoRect.height}px`;
514
+ this.transcriptWindow.style.height = `${appliedHeight}px`;
373
515
  this.transcriptWindow.style.maxHeight = 'none';
374
- this.transcriptWindow.style.width = '400px';
375
- this.transcriptWindow.style.maxWidth = '400px';
516
+ this.transcriptWindow.style.width = `${appliedWidth}px`;
517
+ this.transcriptWindow.style.maxWidth = 'none';
376
518
  this.transcriptWindow.style.borderRadius = '8px';
377
519
  this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
378
520
  this.transcriptWindow.style.borderTop = '';
@@ -388,6 +530,84 @@ export class TranscriptManager {
388
530
  }
389
531
  }
390
532
 
533
+ /**
534
+ * Get available transcript languages from tracks
535
+ */
536
+ getAvailableTranscriptLanguages() {
537
+ const textTracks = this.player.textTracks;
538
+ const languages = new Map();
539
+
540
+ // Collect all caption/subtitle tracks with their languages
541
+ textTracks.forEach(track => {
542
+ if ((track.kind === 'captions' || track.kind === 'subtitles') && track.language) {
543
+ if (!languages.has(track.language)) {
544
+ languages.set(track.language, {
545
+ language: track.language,
546
+ label: track.label || track.language,
547
+ track: track
548
+ });
549
+ }
550
+ }
551
+ });
552
+
553
+ return Array.from(languages.values());
554
+ }
555
+
556
+ /**
557
+ * Update language selector dropdown
558
+ */
559
+ updateLanguageSelector() {
560
+ if (!this.languageSelector) return;
561
+
562
+ this.availableTranscriptLanguages = this.getAvailableTranscriptLanguages();
563
+
564
+ // Clear existing options
565
+ this.languageSelector.innerHTML = '';
566
+
567
+ // Only show selector if there are 2+ languages
568
+ if (this.availableTranscriptLanguages.length < 2) {
569
+ this.languageSelector.style.display = 'none';
570
+ return;
571
+ }
572
+
573
+ // Show selector and populate options
574
+ this.languageSelector.style.display = 'block';
575
+
576
+ this.availableTranscriptLanguages.forEach((langInfo, index) => {
577
+ const option = DOMUtils.createElement('option', {
578
+ textContent: langInfo.label,
579
+ attributes: {
580
+ 'value': langInfo.language
581
+ }
582
+ });
583
+ this.languageSelector.appendChild(option);
584
+ });
585
+
586
+ // Set current selection
587
+ if (this.currentTranscriptLanguage) {
588
+ this.languageSelector.value = this.currentTranscriptLanguage;
589
+ } else if (this.availableTranscriptLanguages.length > 0) {
590
+ // Default to first language or active track
591
+ const activeTrack = this.player.textTracks.find(
592
+ track => (track.kind === 'captions' || track.kind === 'subtitles') && track.mode === 'showing'
593
+ );
594
+ this.currentTranscriptLanguage = activeTrack ? activeTrack.language : this.availableTranscriptLanguages[0].language;
595
+ this.languageSelector.value = this.currentTranscriptLanguage;
596
+ }
597
+
598
+ // Remove existing change listener if any
599
+ if (this.languageSelectorHandler) {
600
+ this.languageSelector.removeEventListener('change', this.languageSelectorHandler);
601
+ }
602
+
603
+ // Handle language change
604
+ this.languageSelectorHandler = (e) => {
605
+ this.currentTranscriptLanguage = e.target.value;
606
+ this.loadTranscriptData();
607
+ };
608
+ this.languageSelector.addEventListener('change', this.languageSelectorHandler);
609
+ }
610
+
391
611
  /**
392
612
  * Load transcript data from caption/subtitle tracks
393
613
  */
@@ -396,13 +616,39 @@ export class TranscriptManager {
396
616
  this.transcriptContent.innerHTML = '';
397
617
 
398
618
  // Get all text tracks
399
- const textTracks = Array.from(this.player.element.textTracks);
619
+ const textTracks = this.player.textTracks;
620
+
621
+ // Find track for selected language, or default to first available
622
+ let captionTrack = null;
623
+ if (this.currentTranscriptLanguage) {
624
+ captionTrack = textTracks.find(
625
+ track => (track.kind === 'captions' || track.kind === 'subtitles') &&
626
+ track.language === this.currentTranscriptLanguage
627
+ );
628
+ }
629
+
630
+ // Fallback to first available caption/subtitle track
631
+ if (!captionTrack) {
632
+ captionTrack = textTracks.find(
633
+ track => track.kind === 'captions' || track.kind === 'subtitles'
634
+ );
635
+ if (captionTrack) {
636
+ this.currentTranscriptLanguage = captionTrack.language;
637
+ }
638
+ }
639
+
640
+ // Find description track matching the selected language
641
+ let descriptionTrack = null;
642
+ if (this.currentTranscriptLanguage) {
643
+ descriptionTrack = textTracks.find(
644
+ track => track.kind === 'descriptions' && track.language === this.currentTranscriptLanguage
645
+ );
646
+ }
647
+ // Fallback to first available description track if no match found
648
+ if (!descriptionTrack) {
649
+ descriptionTrack = textTracks.find(track => track.kind === 'descriptions');
650
+ }
400
651
 
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
652
  const metadataTrack = textTracks.find(track => track.kind === 'metadata');
407
653
 
408
654
  // We need at least one track type
@@ -443,7 +689,7 @@ export class TranscriptManager {
443
689
  });
444
690
 
445
691
  // Fallback timeout
446
- setTimeout(() => {
692
+ this.setManagedTimeout(() => {
447
693
  this.loadTranscriptData();
448
694
  }, 500);
449
695
 
@@ -489,28 +735,84 @@ export class TranscriptManager {
489
735
 
490
736
  // Apply current styles to newly loaded entries
491
737
  this.applyTranscriptStyles();
738
+
739
+ // Update language selector after loading
740
+ this.updateLanguageSelector();
741
+ }
742
+
743
+ /**
744
+ * Setup metadata handling on player load
745
+ * This runs independently of transcript loading
746
+ */
747
+ setupMetadataHandlingOnLoad() {
748
+ // Wait for metadata to be loaded
749
+ const setupMetadata = () => {
750
+ const textTracks = this.player.textTracks;
751
+ const metadataTrack = textTracks.find(track => track.kind === 'metadata');
752
+
753
+ if (metadataTrack) {
754
+ // Enable the metadata track so cuechange events fire
755
+ // Use 'hidden' mode so it doesn't display anything, but events still work
756
+ if (metadataTrack.mode === 'disabled') {
757
+ metadataTrack.mode = 'hidden';
758
+ }
759
+
760
+ // Check if we already added the listener
761
+ if (this.metadataCueChangeHandler) {
762
+ metadataTrack.removeEventListener('cuechange', this.metadataCueChangeHandler);
763
+ }
764
+
765
+ // Add event listener for cue changes
766
+ this.metadataCueChangeHandler = () => {
767
+ const activeCues = Array.from(metadataTrack.activeCues || []);
768
+ if (activeCues.length > 0) {
769
+ // Debug logging (can be removed in production)
770
+ if (this.player.options.debug) {
771
+ console.log('[VidPly Metadata] Active cues:', activeCues.map(c => ({
772
+ start: c.startTime,
773
+ end: c.endTime,
774
+ text: c.text
775
+ })));
776
+ }
777
+ }
778
+ activeCues.forEach(cue => {
779
+ this.handleMetadataCue(cue);
780
+ });
781
+ };
782
+
783
+ metadataTrack.addEventListener('cuechange', this.metadataCueChangeHandler);
784
+
785
+ // Debug: Log metadata track setup
786
+ if (this.player.options.debug) {
787
+ const cueCount = metadataTrack.cues ? metadataTrack.cues.length : 0;
788
+ console.log('[VidPly Metadata] Track enabled,', cueCount, 'cues available');
789
+ }
790
+ } else if (this.player.options.debug) {
791
+ console.warn('[VidPly Metadata] No metadata track found');
792
+ }
793
+ };
794
+
795
+ // Try immediately
796
+ setupMetadata();
797
+
798
+ // Also try after loadedmetadata event
799
+ this.player.on('loadedmetadata', setupMetadata);
492
800
  }
493
801
 
494
802
  /**
495
803
  * Setup metadata handling
496
804
  * Metadata cues are not displayed but can be used programmatically
805
+ * This is called when transcript data is loaded (for storing cues)
497
806
  */
498
807
  setupMetadataHandling() {
499
808
  if (!this.metadataCues || this.metadataCues.length === 0) {
500
809
  return;
501
810
  }
502
811
 
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
- });
812
+ // The actual event handling is set up in setupMetadataHandlingOnLoad()
813
+ // This method just stores the cues for reference
814
+ if (this.player.options.debug) {
815
+ console.log('[VidPly Metadata]', this.metadataCues.length, 'cues stored from transcript load');
514
816
  }
515
817
  }
516
818
 
@@ -521,6 +823,14 @@ export class TranscriptManager {
521
823
  handleMetadataCue(cue) {
522
824
  const text = cue.text.trim();
523
825
 
826
+ // Debug logging
827
+ if (this.player.options.debug) {
828
+ console.log('[VidPly Metadata] Processing cue:', {
829
+ time: cue.startTime,
830
+ text: text
831
+ });
832
+ }
833
+
524
834
  // Emit a generic metadata event that developers can listen to
525
835
  this.player.emit('metadata', {
526
836
  time: cue.startTime,
@@ -531,16 +841,39 @@ export class TranscriptManager {
531
841
 
532
842
  // Parse for specific commands (examples based on wwa_meta.vtt format)
533
843
  if (text.includes('PAUSE')) {
534
- // Emit pause suggestion event (don't auto-pause, let developer decide)
844
+ // Automatically pause the video
845
+ if (!this.player.state.paused) {
846
+ if (this.player.options.debug) {
847
+ console.log('[VidPly Metadata] Pausing video at', cue.startTime);
848
+ }
849
+ this.player.pause();
850
+ }
851
+ // Also emit event for developers who want to listen
535
852
  this.player.emit('metadata:pause', { time: cue.startTime, text: text });
536
853
  }
537
854
 
538
855
  // Parse for focus directives
539
856
  const focusMatch = text.match(/FOCUS:([\w#-]+)/);
540
857
  if (focusMatch) {
858
+ const targetSelector = focusMatch[1];
859
+ // Automatically focus the target element
860
+ const targetElement = document.querySelector(targetSelector);
861
+ if (targetElement) {
862
+ if (this.player.options.debug) {
863
+ console.log('[VidPly Metadata] Focusing element:', targetSelector);
864
+ }
865
+ // Use setTimeout to ensure DOM is ready
866
+ this.setManagedTimeout(() => {
867
+ targetElement.focus();
868
+ }, 10);
869
+ } else if (this.player.options.debug) {
870
+ console.warn('[VidPly Metadata] Element not found:', targetSelector);
871
+ }
872
+ // Also emit event for developers who want to listen
541
873
  this.player.emit('metadata:focus', {
542
874
  time: cue.startTime,
543
- target: focusMatch[1],
875
+ target: targetSelector,
876
+ element: targetElement,
544
877
  text: text
545
878
  });
546
879
  }
@@ -548,6 +881,9 @@ export class TranscriptManager {
548
881
  // Parse for hashtag references
549
882
  const hashtags = text.match(/#[\w-]+/g);
550
883
  if (hashtags) {
884
+ if (this.player.options.debug) {
885
+ console.log('[VidPly Metadata] Hashtags found:', hashtags);
886
+ }
551
887
  this.player.emit('metadata:hashtags', {
552
888
  time: cue.startTime,
553
889
  hashtags: hashtags,
@@ -668,7 +1004,7 @@ export class TranscriptManager {
668
1004
  * Scroll transcript window to show active entry
669
1005
  */
670
1006
  scrollToEntry(entryElement) {
671
- if (!this.transcriptContent) return;
1007
+ if (!this.transcriptContent || !this.autoscrollEnabled) return;
672
1008
 
673
1009
  const contentRect = this.transcriptContent.getBoundingClientRect();
674
1010
  const entryRect = entryElement.getBoundingClientRect();
@@ -683,6 +1019,15 @@ export class TranscriptManager {
683
1019
  });
684
1020
  }
685
1021
  }
1022
+
1023
+ /**
1024
+ * Save autoscroll preference to localStorage
1025
+ */
1026
+ saveAutoscrollPreference() {
1027
+ const savedPreferences = this.storage.getTranscriptPreferences() || {};
1028
+ savedPreferences.autoscroll = this.autoscrollEnabled;
1029
+ this.storage.saveTranscriptPreferences(savedPreferences);
1030
+ }
686
1031
 
687
1032
  /**
688
1033
  * Setup drag and drop functionality
@@ -690,352 +1035,127 @@ export class TranscriptManager {
690
1035
  setupDragAndDrop() {
691
1036
  if (!this.transcriptHeader || !this.transcriptWindow) return;
692
1037
 
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
- };
1038
+ // Check if we're on mobile and not in fullscreen (no dragging in this case)
1039
+ const isMobile = window.innerWidth < 640;
1040
+ const isFullscreen = this.player.state.fullscreen;
1041
+
1042
+ if (isMobile && !isFullscreen) {
1043
+ return; // No drag/resize on mobile (not fullscreen)
1044
+ }
763
1045
 
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();
1046
+ // Create DraggableResizable utility
1047
+ this.draggableResizable = new DraggableResizable(this.transcriptWindow, {
1048
+ dragHandle: this.transcriptHeader,
1049
+ resizeHandles: this.transcriptResizeHandles,
1050
+ constrainToViewport: true,
1051
+ classPrefix: `${this.player.options.classPrefix}-transcript`,
1052
+ keyboardDragKey: 'd',
1053
+ keyboardResizeKey: 'r',
1054
+ keyboardStep: 10,
1055
+ keyboardStepLarge: 50,
1056
+ minWidth: 300,
1057
+ minHeight: 200,
1058
+ maxWidth: () => Math.max(320, window.innerWidth - 40),
1059
+ maxHeight: () => Math.max(200, window.innerHeight - 120),
1060
+ pointerResizeIndicatorText: i18n.t('transcript.resizeModeHint'),
1061
+ onPointerResizeToggle: (enabled) => this.onPointerResizeModeChange(enabled),
1062
+ onDragStart: (e) => {
1063
+ // Don't drag if clicking on certain elements
1064
+ const ignoreSelectors = [
1065
+ `.${this.player.options.classPrefix}-transcript-close`,
1066
+ `.${this.player.options.classPrefix}-transcript-settings`,
1067
+ `.${this.player.options.classPrefix}-transcript-language-select`,
1068
+ `.${this.player.options.classPrefix}-transcript-settings-menu`,
1069
+ `.${this.player.options.classPrefix}-transcript-style-dialog`
1070
+ ];
1071
+
1072
+ for (const selector of ignoreSelectors) {
1073
+ if (e.target.closest(selector)) {
1074
+ return false; // Prevent drag
1075
+ }
1076
+ }
1077
+
1078
+ return true; // Allow drag
776
1079
  }
777
- };
1080
+ });
778
1081
 
779
- this.handlers.touchend = () => {
780
- if (this.isDragging) {
781
- // Stop dragging
782
- this.stopDragging();
783
- }
784
- };
1082
+ // Add custom keyboard handler for special keys (Escape, Home)
1083
+ this.customKeyHandler = (e) => {
1084
+ const key = e.key.toLowerCase();
1085
+ const alreadyPrevented = e.defaultPrevented;
785
1086
 
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
1087
+ if (key === 'home') {
795
1088
  e.preventDefault();
796
1089
  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;
1090
+ if (this.draggableResizable) {
1091
+ if (this.draggableResizable.pointerResizeMode) {
1092
+ this.draggableResizable.disablePointerResizeMode();
1093
+ }
1094
+ this.draggableResizable.manuallyPositioned = false;
1095
+ this.positionTranscript();
1096
+ this.updateResizeOptionState();
1097
+ this.announceLive(i18n.t('transcript.positionReset'));
832
1098
  }
833
-
834
- // Set new position directly
835
- this.transcriptWindow.style.left = `${newX}px`;
836
- this.transcriptWindow.style.top = `${newY}px`;
837
1099
  return;
838
1100
  }
839
1101
 
840
- // Handle other special keys
841
- if (e.key === 'Home') {
1102
+ if (key === 'r') {
1103
+ if (alreadyPrevented) {
1104
+ return;
1105
+ }
842
1106
  e.preventDefault();
843
1107
  e.stopPropagation();
844
- this.resetPosition();
1108
+ const enabled = this.toggleResizeMode();
1109
+ if (enabled) {
1110
+ this.transcriptWindow.focus();
1111
+ }
845
1112
  return;
846
1113
  }
847
1114
 
848
- if (e.key === 'Escape') {
1115
+ if (key === 'escape') {
849
1116
  e.preventDefault();
850
1117
  e.stopPropagation();
1118
+ if (this.draggableResizable && this.draggableResizable.pointerResizeMode) {
1119
+ this.draggableResizable.disablePointerResizeMode();
1120
+ return;
1121
+ }
851
1122
  if (this.styleDialogVisible) {
852
- // Close style dialog first
853
1123
  this.hideStyleDialog();
854
- } else if (this.keyboardDragMode) {
855
- // Exit drag mode
856
- this.disableKeyboardDragMode();
1124
+ } else if (this.draggableResizable && this.draggableResizable.keyboardDragMode) {
1125
+ this.draggableResizable.disableKeyboardDragMode();
1126
+ this.announceLive(i18n.t('transcript.dragModeDisabled'));
857
1127
  } else if (this.settingsMenuVisible) {
858
- // Close settings menu
859
1128
  this.hideSettingsMenu();
860
1129
  } else {
861
- // Close transcript
862
- this.hideTranscript();
1130
+ this.hideTranscript({ focusButton: true });
863
1131
  }
864
1132
  return;
865
1133
  }
866
1134
  };
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
1135
 
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';
1136
+ this.transcriptWindow.addEventListener('keydown', this.customKeyHandler);
911
1137
  }
912
1138
 
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
1139
 
979
1140
  /**
980
1141
  * Toggle keyboard drag mode
981
1142
  */
982
1143
  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();
1144
+ if (this.draggableResizable) {
1145
+ const wasEnabled = this.draggableResizable.keyboardDragMode;
1146
+ this.draggableResizable.toggleKeyboardDragMode();
1147
+ const isEnabled = this.draggableResizable.keyboardDragMode;
1148
+ if (!wasEnabled && isEnabled) {
1149
+ this.enableMoveMode();
1150
+ }
1151
+
1152
+ // Hide settings menu if open
1153
+ if (this.settingsMenuVisible) {
1154
+ this.hideSettingsMenu();
1155
+ }
1156
+
1157
+ // Focus the window for keyboard navigation
1158
+ this.transcriptWindow.focus();
1039
1159
  }
1040
1160
  }
1041
1161
 
@@ -1071,6 +1191,16 @@ export class TranscriptManager {
1071
1191
  if (this.settingsMenu) {
1072
1192
  this.settingsMenu.style.display = 'block';
1073
1193
  this.settingsMenuVisible = true;
1194
+ if (this.settingsButton) {
1195
+ this.settingsButton.setAttribute('aria-expanded', 'true');
1196
+ }
1197
+ this.updateResizeOptionState();
1198
+ setTimeout(() => {
1199
+ const firstItem = this.settingsMenu.querySelector(`.${this.player.options.classPrefix}-transcript-settings-item`);
1200
+ if (firstItem) {
1201
+ firstItem.focus();
1202
+ }
1203
+ }, 0);
1074
1204
  return;
1075
1205
  }
1076
1206
  // Create settings menu
@@ -1126,19 +1256,38 @@ export class TranscriptManager {
1126
1256
  className: `${this.player.options.classPrefix}-transcript-settings-item`,
1127
1257
  attributes: {
1128
1258
  'type': 'button',
1129
- 'aria-label': i18n.t('transcript.resizeWindow')
1259
+ 'aria-label': i18n.t('transcript.resizeWindow'),
1260
+ 'aria-pressed': 'false'
1130
1261
  }
1131
1262
  });
1132
1263
  const resizeIcon = createIconElement('resize');
1133
1264
  const resizeText = DOMUtils.createElement('span', {
1265
+ className: `${this.player.options.classPrefix}-transcript-settings-text`,
1134
1266
  textContent: i18n.t('transcript.resizeWindow')
1135
1267
  });
1136
1268
  resizeOption.appendChild(resizeIcon);
1137
1269
  resizeOption.appendChild(resizeText);
1138
- resizeOption.addEventListener('click', () => {
1139
- this.toggleResizeMode();
1140
- this.hideSettingsMenu();
1270
+ resizeOption.addEventListener('click', (event) => {
1271
+ event.preventDefault();
1272
+ event.stopPropagation();
1273
+
1274
+ const enabled = this.toggleResizeMode({ focus: false });
1275
+
1276
+ if (enabled) {
1277
+ this.hideSettingsMenu({ focusButton: false });
1278
+ // Focus transcript window after handles appear
1279
+ this.setManagedTimeout(() => {
1280
+ if (this.transcriptWindow) {
1281
+ this.transcriptWindow.focus();
1282
+ }
1283
+ }, 20);
1284
+ } else {
1285
+ this.hideSettingsMenu({ focusButton: true });
1286
+ }
1141
1287
  });
1288
+ this.resizeOptionButton = resizeOption;
1289
+ this.resizeOptionText = resizeText;
1290
+ this.updateResizeOptionState();
1142
1291
 
1143
1292
  // Close option
1144
1293
  const closeOption = DOMUtils.createElement('button', {
@@ -1178,6 +1327,7 @@ export class TranscriptManager {
1178
1327
  if (this.settingsButton) {
1179
1328
  this.settingsButton.setAttribute('aria-expanded', 'true');
1180
1329
  }
1330
+ this.updateResizeOptionState();
1181
1331
 
1182
1332
  // Focus first menu item
1183
1333
  setTimeout(() => {
@@ -1191,7 +1341,7 @@ export class TranscriptManager {
1191
1341
  /**
1192
1342
  * Hide settings menu
1193
1343
  */
1194
- hideSettingsMenu() {
1344
+ hideSettingsMenu({ focusButton = true } = {}) {
1195
1345
  if (this.settingsMenu) {
1196
1346
  this.settingsMenu.style.display = 'none';
1197
1347
  this.settingsMenuVisible = false;
@@ -1200,8 +1350,10 @@ export class TranscriptManager {
1200
1350
  // Update aria-expanded
1201
1351
  if (this.settingsButton) {
1202
1352
  this.settingsButton.setAttribute('aria-expanded', 'false');
1203
- // Return focus to settings button
1204
- this.settingsButton.focus();
1353
+ if (focusButton) {
1354
+ // Return focus to settings button
1355
+ this.settingsButton.focus();
1356
+ }
1205
1357
  }
1206
1358
  }
1207
1359
  }
@@ -1210,6 +1362,8 @@ export class TranscriptManager {
1210
1362
  * Enable move mode (gives visual feedback)
1211
1363
  */
1212
1364
  enableMoveMode() {
1365
+ this.hideResizeModeIndicator();
1366
+
1213
1367
  // Add visual feedback for move mode
1214
1368
  this.transcriptWindow.classList.add(`${this.player.options.classPrefix}-transcript-move-mode`);
1215
1369
 
@@ -1232,193 +1386,82 @@ export class TranscriptManager {
1232
1386
  /**
1233
1387
  * Toggle resize mode
1234
1388
  */
1235
- toggleResizeMode() {
1236
- this.resizeEnabled = !this.resizeEnabled;
1237
-
1238
- if (this.resizeEnabled) {
1239
- this.enableResizeHandles();
1240
- } else {
1241
- this.disableResizeHandles();
1389
+ toggleResizeMode({ focus = true } = {}) {
1390
+ if (!this.draggableResizable) {
1391
+ return false;
1242
1392
  }
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
1393
 
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
- };
1394
+ if (this.draggableResizable.pointerResizeMode) {
1395
+ this.draggableResizable.disablePointerResizeMode({ focus });
1396
+ return false;
1397
+ }
1289
1398
 
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);
1399
+ this.draggableResizable.enablePointerResizeMode({ focus });
1400
+ return true;
1294
1401
  }
1295
1402
 
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());
1403
+ updateResizeOptionState() {
1404
+ if (!this.resizeOptionButton) {
1405
+ return;
1406
+ }
1407
+
1408
+ const isEnabled = !!(this.draggableResizable && this.draggableResizable.pointerResizeMode);
1409
+ const label = isEnabled
1410
+ ? (i18n.t('transcript.disableResizeWindow') || 'Disable Resize Mode')
1411
+ : i18n.t('transcript.resizeWindow');
1305
1412
 
1306
- this.transcriptWindow.classList.remove(`${this.player.options.classPrefix}-transcript-resizable`);
1413
+ this.resizeOptionButton.setAttribute('aria-pressed', isEnabled ? 'true' : 'false');
1414
+ this.resizeOptionButton.setAttribute('aria-label', label);
1415
+ this.resizeOptionButton.setAttribute('title', label);
1307
1416
 
1308
- // Remove resize event handlers
1309
- if (this.handlers.resizeMove) {
1310
- document.removeEventListener('mousemove', this.handlers.resizeMove);
1417
+ if (this.resizeOptionText) {
1418
+ this.resizeOptionText.textContent = label;
1311
1419
  }
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
1420
  }
1320
1421
 
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
- }
1422
+ showResizeModeIndicator() {
1423
+ if (!this.transcriptHeader) {
1424
+ return;
1425
+ }
1341
1426
 
1342
- /**
1343
- * Perform resize
1344
- */
1345
- performResize(clientX, clientY) {
1346
- if (!this.isResizing) return;
1427
+ this.hideResizeModeIndicator();
1347
1428
 
1348
- const deltaX = clientX - this.resizeStartX;
1349
- const deltaY = clientY - this.resizeStartY;
1429
+ const indicator = DOMUtils.createElement('div', {
1430
+ className: `${this.player.options.classPrefix}-transcript-resize-tooltip`,
1431
+ textContent: i18n.t('transcript.resizeModeHint') || 'Resize handles enabled. Drag edges or corners to adjust. Press Esc or R to exit.'
1432
+ });
1350
1433
 
1351
- let newWidth = this.resizeStartWidth;
1352
- let newHeight = this.resizeStartHeight;
1434
+ this.transcriptHeader.appendChild(indicator);
1435
+ this.resizeModeIndicator = indicator;
1353
1436
 
1354
- const direction = this.resizeDirection;
1437
+ this.resizeModeIndicatorTimeout = this.setManagedTimeout(() => {
1438
+ this.hideResizeModeIndicator();
1439
+ }, 3000);
1440
+ }
1355
1441
 
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;
1442
+ hideResizeModeIndicator() {
1443
+ if (this.resizeModeIndicatorTimeout) {
1444
+ this.clearManagedTimeout(this.resizeModeIndicatorTimeout);
1445
+ this.resizeModeIndicatorTimeout = null;
1368
1446
  }
1369
1447
 
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`;
1448
+ if (this.resizeModeIndicator && this.resizeModeIndicator.parentNode) {
1449
+ this.resizeModeIndicator.remove();
1393
1450
  }
1394
- }
1395
1451
 
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 = '';
1452
+ this.resizeModeIndicator = null;
1405
1453
  }
1406
1454
 
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';
1455
+ onPointerResizeModeChange(enabled) {
1456
+ this.updateResizeOptionState();
1457
+
1458
+ if (enabled) {
1459
+ this.showResizeModeIndicator();
1460
+ this.announceLive(i18n.t('transcript.resizeModeEnabled'));
1461
+ } else {
1462
+ this.hideResizeModeIndicator();
1463
+ this.announceLive(i18n.t('transcript.resizeModeDisabled'));
1464
+ }
1422
1465
  }
1423
1466
 
1424
1467
  /**
@@ -1718,35 +1761,58 @@ export class TranscriptManager {
1718
1761
  });
1719
1762
  }
1720
1763
 
1764
+ /**
1765
+ * Set a managed timeout that will be cleaned up on destroy
1766
+ * @param {Function} callback - Callback function
1767
+ * @param {number} delay - Delay in milliseconds
1768
+ * @returns {number} Timeout ID
1769
+ */
1770
+ setManagedTimeout(callback, delay) {
1771
+ const timeoutId = setTimeout(() => {
1772
+ this.timeouts.delete(timeoutId);
1773
+ callback();
1774
+ }, delay);
1775
+ this.timeouts.add(timeoutId);
1776
+ return timeoutId;
1777
+ }
1778
+
1779
+ /**
1780
+ * Clear a managed timeout
1781
+ * @param {number} timeoutId - Timeout ID to clear
1782
+ */
1783
+ clearManagedTimeout(timeoutId) {
1784
+ if (timeoutId) {
1785
+ clearTimeout(timeoutId);
1786
+ this.timeouts.delete(timeoutId);
1787
+ }
1788
+ }
1789
+
1721
1790
  /**
1722
1791
  * Cleanup
1723
1792
  */
1724
1793
  destroy() {
1725
- // Disable modes if active
1726
- if (this.resizeEnabled) {
1727
- this.disableResizeHandles();
1794
+ this.hideResizeModeIndicator();
1795
+
1796
+ // Destroy draggableResizable utility
1797
+ if (this.draggableResizable) {
1798
+ if (this.draggableResizable.pointerResizeMode) {
1799
+ this.draggableResizable.disablePointerResizeMode();
1800
+ this.updateResizeOptionState();
1801
+ }
1802
+ this.draggableResizable.destroy();
1803
+ this.draggableResizable = null;
1728
1804
  }
1729
- if (this.keyboardDragMode) {
1730
- this.disableKeyboardDragMode();
1805
+
1806
+ // Remove custom key handler
1807
+ if (this.transcriptWindow && this.customKeyHandler) {
1808
+ this.transcriptWindow.removeEventListener('keydown', this.customKeyHandler);
1809
+ this.customKeyHandler = null;
1731
1810
  }
1732
1811
 
1733
1812
  // Remove timeupdate listener from player
1734
1813
  if (this.handlers.timeupdate) {
1735
1814
  this.player.off('timeupdate', this.handlers.timeupdate);
1736
1815
  }
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
1816
 
1751
1817
  // Remove settings button event listeners
1752
1818
  if (this.settingsButton) {
@@ -1763,19 +1829,7 @@ export class TranscriptManager {
1763
1829
  this.styleDialog.removeEventListener('keydown', this.handlers.styleDialogKeydown);
1764
1830
  }
1765
1831
 
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
- }
1832
+ // Remove document click listener
1779
1833
  if (this.handlers.documentClick) {
1780
1834
  document.removeEventListener('click', this.handlers.documentClick);
1781
1835
  }
@@ -1785,6 +1839,10 @@ export class TranscriptManager {
1785
1839
  window.removeEventListener('resize', this.handlers.resize);
1786
1840
  }
1787
1841
 
1842
+ // Cleanup all managed timeouts
1843
+ this.timeouts.forEach(timeoutId => clearTimeout(timeoutId));
1844
+ this.timeouts.clear();
1845
+
1788
1846
  // Clear handlers
1789
1847
  this.handlers = null;
1790
1848
 
@@ -1799,5 +1857,14 @@ export class TranscriptManager {
1799
1857
  this.transcriptEntries = [];
1800
1858
  this.settingsMenu = null;
1801
1859
  this.styleDialog = null;
1860
+ this.transcriptResizeHandles = [];
1861
+ this.resizeOptionButton = null;
1862
+ this.resizeOptionText = null;
1863
+ this.liveRegion = null;
1864
+ }
1865
+
1866
+ announceLive(message) {
1867
+ if (!this.liveRegion) return;
1868
+ this.liveRegion.textContent = message || '';
1802
1869
  }
1803
1870
  }