myetv-player 1.7.1 → 1.7.2

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,20 +1,21 @@
1
1
  /**
2
2
  * MYETV Player - Auto Subtitles Plugin
3
- * Automatically generates subtitles via Transformers.js + Whisper (client-side, no FFmpeg, no server)
3
+ * Automatically generates subtitles via Transformers.js + Whisper
4
+ * OR Translates existing player subtitles on-the-fly.
4
5
  *
5
6
  * Compatible with myetv-player plugin system (registerMYETVPlugin)
6
7
  * Author: Oscar Cosimo - MYETV
7
8
  *
8
9
  * Options:
9
- * language {string} Transcription language ('italian','english','it','en',...). Default: null (auto-detect)
10
- * modelSize {string} 'tiny' (~39MB), 'base' (~74MB), 'small' (~244MB). Default: 'base'
11
- * autoGenerate {boolean} Auto-generate subtitles on video load. Default: false
12
- * autoTranslation {string} Auto-translate subtitles into this language on load (ISO 2-letter code, e.g. 'en','it'). Default: null
13
- * showButton {boolean} Show button in the control bar. Default: true
14
- * position {string} 'right', 'left', or 'topbar'. Default: 'right'
15
- * subtitleStyle {object} Custom CSS style object for the subtitle text div
16
- * cacheEnabled {boolean} Cache transcription in localStorage. Default: true
17
- * idCache {string} Stable unique ID for localStorage cache key
10
+ * language {string} Transcription language ('italian','english','it','en',...). Default: null (auto-detect)
11
+ * modelSize {string} 'tiny' (~39MB), 'base' (~74MB), 'small' (~244MB). Default: 'base'
12
+ * autoGenerate {boolean} Auto-generate subtitles on video load. Default: false
13
+ * autoTranslation {string} Auto-translate subtitles into this language on load (ISO 2-letter code, e.g. 'en','it'). Default: null
14
+ * showButton {boolean} Show button in the control bar. Default: true
15
+ * position {string} 'right', 'left', or 'topbar'. Default: 'right'
16
+ * subtitleStyle {object} Custom CSS style object for the subtitle text div
17
+ * cacheEnabled {boolean} Cache transcription and translations in localStorage. Default: true
18
+ * idCache {string} Stable unique ID for localStorage cache key
18
19
  */
19
20
 
20
21
  (function () {
@@ -212,7 +213,9 @@
212
213
  @media (max-width: 600px) {
213
214
  .myetv-autosub-overlay { bottom: 60px; width: 96%; }
214
215
  .myetv-autosub-text { font-size: clamp(0.6em, 3.5vw, 0.85em); padding: 3px 8px 4px; }
215
- .myetv-autosub-drag-handle { display: none; } /* drag handle hidden on touch devices */
216
+ .myetv-autosub-drag-handle { display: none; }
217
+ .myetv-autosub-overlay { pointer-events: all; cursor: grab; }
218
+ .myetv-autosub-overlay.dragging { cursor: grabbing; }
216
219
  }
217
220
  @media (max-width: 400px) {
218
221
  .myetv-autosub-text { font-size: clamp(0.55em, 4vw, 0.75em); }
@@ -221,11 +224,9 @@
221
224
 
222
225
  const ICON_CC = `<svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-9 8H9.5v-.5h-2v3h2V14H11v1c0 .55-.45 1-1 1H7c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1zm7 0h-1.5v-.5h-2v3h2V14H18v1c0 .55-.45 1-1 1h-3c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1z"/></svg>`;
223
226
  const ICON_LOADING = `<svg viewBox="0 0 24 24">
224
- <!-- cerchio rotante -->
225
227
  <path d="M12 2A10 10 0 0 1 22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none">
226
228
  <animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>
227
229
  </path>
228
- <!-- icona CC fissa al centro, scala ridotta -->
229
230
  <g transform="translate(4, 5) scale(0.65)">
230
231
  <path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-9 8H9.5v-.5h-2v3h2V14H11v1c0 .55-.45 1-1 1H7c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1zm7 0h-1.5v-.5h-2v3h2V14H18v1c0 .55-.45 1-1 1h-3c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1v1z"/>
231
232
  </g>
@@ -293,20 +294,25 @@
293
294
  modelSize: 'base',
294
295
  autoGenerate: false,
295
296
  showButton: true,
296
- position: 'right', // 'left' | 'right' | 'topbar'
297
+ position: 'right',
297
298
  subtitleStyle: {},
298
299
  cacheEnabled: true,
299
300
  idCache: null,
300
- autoTranslation: null, // ISO 2-letter code, e.g. 'en', 'it'
301
- translationEngine: null, // null = MyMemory (default), oppure { type: 'libretranslate', url: 'https://...', apiKey: '' }
301
+ autoTranslation: null,
302
+ translationEngine: null,
302
303
  }, options);
303
304
 
304
305
  this.isGenerating = false;
305
- this.subtitles = []; // { start, end, text } original segments
306
- this.subtitlesTrans = []; // { start, end, text } translated segments (in-memory cache)
307
- this.translateLang = 'off'; // currently active translation language
308
- this._transCache = {}; // { langCode: [{start,end,text}] }
306
+ this.subtitles = []; // Original segments (generated OR imported)
307
+ this.subtitlesTrans = []; // Translated segments
308
+ this.translateLang = 'off';
309
+ this._transCache = {}; // Persistent cache for translations { langCode: [...] }
309
310
  this.subVisible = false;
311
+
312
+ // Flags for Native Subtitle Integration
313
+ this.hasNativeTracks = false;
314
+ this.isImportedSubs = false;
315
+
310
316
  this.updateInterval = null;
311
317
  this.btnWrapEl = null;
312
318
  this.btnEl = null;
@@ -317,7 +323,7 @@
317
323
  this._worker = null;
318
324
  // Drag state
319
325
  this._drag = { active: false, startX: 0, startY: 0, origLeft: 0, origTop: 0 };
320
- // Overlay position (absolute px, or null → uses default bottom:72px from CSS)
326
+ // Overlay position
321
327
  this._overlayPos = null;
322
328
  }
323
329
 
@@ -326,32 +332,118 @@
326
332
  this._createOverlay();
327
333
  this._createPanel();
328
334
  if (this.opts.showButton) this._createButton();
335
+
336
+ // Integrate with player initialization lifecycle
337
+ this.player.addEventListener('playerready', () => {
338
+ this._checkForNativeTracks();
339
+
340
+ // If autoTranslation is enabled and we have native tracks, import immediately!
341
+ if (this.opts.autoTranslation && this.hasNativeTracks) {
342
+ // Give fetch a moment to load custom tracks via the core player
343
+ const attemptImport = setInterval(() => {
344
+ if (this._importPlayerSubtitles()) {
345
+ clearInterval(attemptImport);
346
+ if (this.player.options.debug) console.log('[AutoSub] Auto-imported native tracks for translation');
347
+ this.subVisible = true;
348
+ this._startDisplay();
349
+ this._setBtnActive(true);
350
+
351
+ // Trigger translation
352
+ this._setTranslationLang(this.opts.autoTranslation.toLowerCase().trim().slice(0, 2));
353
+ }
354
+ }, 500);
355
+ // Stop trying after 5 seconds to avoid infinite loops
356
+ setTimeout(() => clearInterval(attemptImport), 5000);
357
+ }
358
+ });
359
+
329
360
  if (this.opts.autoTranslation) {
330
361
  this._scheduleAutoTranslation();
331
362
  }
363
+
332
364
  if (this.opts.autoGenerate) {
333
- this.video.addEventListener('loadedmetadata', () => this.generate(), { once: true });
365
+ this.video.addEventListener('loadedmetadata', () => {
366
+ // Do NOT generate if the player already loaded native tracks
367
+ if (!this._checkForNativeTracks()) {
368
+ this.generate();
369
+ }
370
+ }, { once: true });
334
371
  }
372
+
335
373
  if (this.player.options.debug) console.log('[AutoSub] Plugin setup complete');
336
374
  }
337
375
 
376
+ // Checks if the player has <track> elements or VTT/SRT data
377
+ _checkForNativeTracks() {
378
+ if (this.player.textTracks && this.player.textTracks.length > 0) {
379
+ this.hasNativeTracks = true;
380
+ if (this.btnEl) {
381
+ this.btnEl.setAttribute('title', 'Translate Subtitles');
382
+ }
383
+ return true;
384
+ }
385
+ return false;
386
+ }
387
+
388
+ // Steal subtitles from the player's custom parsed arrays
389
+ _importPlayerSubtitles() {
390
+ if (this.player.customSubtitles && this.player.customSubtitles.length > 0) {
391
+ let track = null;
392
+
393
+ // Try current active track first
394
+ if (this.player.currentCustomTrackIndex >= 0) {
395
+ track = this.player.customSubtitles[this.player.currentCustomTrackIndex];
396
+ }
397
+
398
+ // Fallback to first available track with data
399
+ if (!track || !track.subtitles || track.subtitles.length === 0) {
400
+ track = this.player.customSubtitles.find(t => t.subtitles && t.subtitles.length > 0);
401
+ }
402
+
403
+ if (track && track.subtitles && track.subtitles.length > 0) {
404
+ // Deep copy segments
405
+ this.subtitles = track.subtitles.map(s => ({ start: s.start, end: s.end, text: s.text }));
406
+ this.isImportedSubs = true;
407
+
408
+ if (track.language) {
409
+ this.opts.language = track.language;
410
+ this._lastDetectedLang = track.language;
411
+ }
412
+
413
+ // Disable native player subtitles to avoid double rendering (we take control)
414
+ if (typeof this.player.disableSubtitles === 'function') {
415
+ this.player.disableSubtitles();
416
+ }
417
+
418
+ // Load any previously cached translations for this imported subtitle
419
+ const cached = this._loadFromCache();
420
+ if (cached && cached.transCache) {
421
+ this._transCache = cached.transCache;
422
+ }
423
+
424
+ // Save imported state immediately
425
+ this._saveToCache(this.subtitles, null, null, this._lastDetectedLang);
426
+
427
+ return true;
428
+ }
429
+ }
430
+ return false;
431
+ }
432
+
338
433
  _scheduleAutoTranslation() {
339
434
  const lang = this.opts.autoTranslation.toLowerCase().trim().slice(0, 2);
340
-
341
- // Set translateLang immediately so chunk_done incremental translation kicks in right away
342
435
  this.translateLang = lang;
343
436
 
344
437
  let tries = 0;
345
438
  const check = setInterval(() => {
346
439
  tries++;
347
- // Wait until transcription is fully complete, then do a full translation pass
348
- // to catch any chunks that may have been missed or arrived out of order
440
+ // Wait until transcription/import is fully complete
349
441
  if (this.subtitles.length > 0 && !this.isGenerating) {
350
442
  clearInterval(check);
351
443
  if (this.player.options.debug) console.log('[AutoSub] autoTranslation final pass, lang:', lang);
352
- // Reset and retranslate everything cleanly at the end
444
+
353
445
  this.subtitlesTrans = [];
354
- this._transCache = {};
446
+ // Note: We DO NOT clear this._transCache here, to preserve cached translations!
355
447
  this._setTranslationLang(lang);
356
448
  this._updateMenu();
357
449
  return;
@@ -360,30 +452,81 @@
360
452
  }, 1000);
361
453
  }
362
454
 
363
-
364
455
  async handleButtonClick() {
365
- // If cache is enabled and no subtitles are loaded yet, try loading from cache first
456
+ // 1. If native tracks exist but haven't been imported yet, try to import them
457
+ if (this._checkForNativeTracks() && this.subtitles.length === 0) {
458
+ const imported = this._importPlayerSubtitles();
459
+ if (imported) {
460
+ this.subVisible = true;
461
+ this._startDisplay();
462
+ this._setBtnActive(true);
463
+ this._updateMenu();
464
+ if (this.player.options.debug) console.log('[AutoSub] Imported subtitles from player on click');
465
+ this._toggleMenu();
466
+ return;
467
+ } else {
468
+ // Fetch is probably still pending. Check cache just in case we already saved imported subs.
469
+ const cached = this._loadFromCache();
470
+ if (cached && cached.isImportedSubs) {
471
+ this.subtitles = cached.subtitles;
472
+ this._transCache = cached.transCache || {};
473
+ this.isImportedSubs = true;
474
+ this.subVisible = true;
475
+ this._startDisplay();
476
+ this._setBtnActive(true);
477
+ this._updateMenu();
478
+ this._toggleMenu();
479
+ return;
480
+ } else {
481
+ // Let the user know we are processing the native tracks
482
+ this._updatePanel('Reading native subtitles...', 100);
483
+ this._openPanel();
484
+ setTimeout(() => this._closePanel(), 1500);
485
+ return;
486
+ }
487
+ }
488
+ }
489
+
490
+ // 2. If NO native tracks, check cache for Whisper generated ones
366
491
  if (this.subtitles.length === 0 && !this.isGenerating) {
367
492
  const cached = this._loadFromCache();
368
493
  if (cached) {
369
- this.subtitles = cached;
494
+ this.subtitles = cached.subtitles;
495
+ this._transCache = cached.transCache || {};
496
+ this.isImportedSubs = cached.isImportedSubs || false;
497
+
498
+ if (this.opts.autoTranslation) {
499
+ const tLang = this.opts.autoTranslation.toLowerCase().trim().slice(0, 2);
500
+ this.translateLang = tLang;
501
+ if (this._transCache[tLang]) {
502
+ this.subtitlesTrans = [...this._transCache[tLang]];
503
+ }
504
+ }
505
+
370
506
  this.subVisible = true;
371
507
  this._startDisplay();
372
508
  this._setBtnActive(true);
373
509
  this._updateMenu();
510
+
511
+ if (this.translateLang && this.translateLang !== 'off') {
512
+ this._setTranslationLang(this.translateLang);
513
+ }
514
+
374
515
  if (this.player.options.debug)
375
- console.log('[AutoSub] Loaded from cache:', cached.length, 'segments');
376
- // Open menu so the user can see current state and translation options
516
+ console.log('[AutoSub] Loaded from cache:', cached.subtitles.length, 'segments');
517
+
377
518
  this._toggleMenu();
378
519
  return;
379
520
  }
380
521
  }
381
- // No cache found, or subtitles already loaded — just toggle the menu
522
+
523
+ // Default: Toggle the menu
382
524
  this._toggleMenu();
383
525
  }
384
526
 
385
527
  async generate() {
386
- if (this.isGenerating) return;
528
+ // Block generation if we are already using native imported subtitles
529
+ if (this.isGenerating || this.isImportedSubs) return;
387
530
 
388
531
  let partialCache = null;
389
532
 
@@ -391,6 +534,17 @@
391
534
  const cached = this._loadFromCache();
392
535
  if (cached) {
393
536
  this.subtitles = cached.subtitles;
537
+ this._transCache = cached.transCache || {};
538
+ this.isImportedSubs = cached.isImportedSubs || false;
539
+
540
+ if (this.opts.autoTranslation) {
541
+ const tLang = this.opts.autoTranslation.toLowerCase().trim().slice(0, 2);
542
+ this.translateLang = tLang;
543
+ if (this._transCache[tLang]) {
544
+ this.subtitlesTrans = [...this._transCache[tLang]];
545
+ }
546
+ }
547
+
394
548
  this.subVisible = true;
395
549
  this._startDisplay();
396
550
  this._setBtnActive(true);
@@ -401,7 +555,7 @@
401
555
  return;
402
556
  }
403
557
 
404
- partialCache = cached; // Pass to transcribeChunksStreaming
558
+ partialCache = cached;
405
559
  if (this.player.options.debug)
406
560
  console.log('[AutoSub] Partial cache found — resuming from chunk', cached.chunksCompleted, 'of', cached.totalChunks);
407
561
  }
@@ -411,45 +565,30 @@
411
565
  this._setBtnGenerating(true);
412
566
  this._updatePanel('Extracting audio...', 5);
413
567
  try {
414
- this.isGenerating = true;
415
- this._setBtnGenerating(true);
416
- this._updatePanel('Extracting audio...', 5);
417
- try {
418
- const audioData = await this._extractAudio();
419
- this._updatePanel('Downloading Transformers.js bundle...', 30);
420
- await this._ensureBundle();
421
- this._updatePanel('Preparing Whisper worker...', 38);
422
- await this._transcribeChunksStreaming(audioData, partialCache);
423
-
424
- // Sort subtitles by start time — partial cache + new chunks may be unsorted
425
- this.subtitles.sort((a, b) => a.start - b.start);
426
-
427
- // Mark cache as complete
428
- this._saveToCache(this.subtitles, null, null, this._lastDetectedLang || null);
429
- this._updatePanel('Subtitles ready!', 100);
430
- this._setBtnActive(true);
431
- this._updateMenu();
432
-
433
- // Re-trigger translation if one was active, using the now-complete subtitles
434
- if (this.translateLang && this.translateLang !== 'off') {
435
- const lang = this.translateLang;
436
- this.translateLang = 'off'; // reset so setTranslationLang doesn't skip
437
- this.subtitlesTrans = [];
438
- this._transCache = {};
439
- await this._setTranslationLang(lang); // re-translate with full subtitles
440
- }
441
-
442
- setTimeout(() => { this._closePanel(); }, 800);
443
-
444
- if (this.player.options.debug) console.log('[AutoSub] Generation complete:', this.subtitles.length, 'segments');
445
- } catch (err) {
446
- if (this.player.options.debug) console.error('[AutoSub] Generation error:', err);
447
- this._updatePanel('Error: ' + (err.message || String(err)), 0);
448
- } finally {
449
- this.isGenerating = false;
450
- this._setBtnGenerating(false);
568
+ const audioData = await this._extractAudio();
569
+ this._updatePanel('Downloading Transformers.js bundle...', 30);
570
+ await this._ensureBundle();
571
+ this._updatePanel('Preparing Whisper worker...', 38);
572
+ await this._transcribeChunksStreaming(audioData, partialCache);
573
+
574
+ this.subtitles.sort((a, b) => a.start - b.start);
575
+
576
+ this._saveToCache(this.subtitles, null, null, this._lastDetectedLang || null);
577
+ this._updatePanel('Subtitles ready!', 100);
578
+ this._setBtnActive(true);
579
+ this._updateMenu();
580
+
581
+ // Re-trigger translation if one was active
582
+ if (this.translateLang && this.translateLang !== 'off') {
583
+ const lang = this.translateLang;
584
+ this.translateLang = 'off';
585
+ this.subtitlesTrans = [];
586
+ await this._setTranslationLang(lang);
451
587
  }
452
588
 
589
+ setTimeout(() => { this._closePanel(); }, 800);
590
+
591
+ if (this.player.options.debug) console.log('[AutoSub] Generation complete:', this.subtitles.length, 'segments');
453
592
  } catch (err) {
454
593
  if (this.player.options.debug) console.error('[AutoSub] Generation error:', err);
455
594
  this._updatePanel('Error: ' + (err.message || String(err)), 0);
@@ -469,6 +608,10 @@
469
608
  dispose() {
470
609
  this._stopDisplay();
471
610
  this._destroyDrag();
611
+ if (this._resizeObserver) {
612
+ this._resizeObserver.disconnect();
613
+ this._resizeObserver = null;
614
+ }
472
615
  if (this._captureTimer) { clearTimeout(this._captureTimer); this._captureTimer = null; }
473
616
  if (this._captureProgressInterval) { clearInterval(this._captureProgressInterval); this._captureProgressInterval = null; }
474
617
  if (this._worker) { this._worker.terminate(); this._worker = null; }
@@ -507,7 +650,10 @@
507
650
 
508
651
  const text = document.createElement('div');
509
652
  text.className = 'myetv-autosub-text';
510
- Object.assign(text.style, this.opts.subtitleStyle);
653
+
654
+ const styleWithoutFont = Object.assign({}, this.opts.subtitleStyle);
655
+ delete styleWithoutFont.fontSize;
656
+ Object.assign(text.style, styleWithoutFont);
511
657
 
512
658
  overlay.appendChild(handle);
513
659
  overlay.appendChild(text);
@@ -517,11 +663,50 @@
517
663
  this.overlayText = text;
518
664
 
519
665
  this._initDrag(handle, overlay);
666
+
667
+ this._resizeObserver = new ResizeObserver(() => {
668
+ this._applyResponsiveFontSize();
669
+ });
670
+ this._resizeObserver.observe(this.container);
671
+
672
+ this._applyResponsiveFontSize();
673
+ }
674
+
675
+ _applyResponsiveFontSize() {
676
+ if (!this.overlayText) return;
677
+
678
+ const rect = this.container.getBoundingClientRect();
679
+ const w = rect.width;
680
+ const h = rect.height;
681
+
682
+ const REFERENCE_WIDTH = 800;
683
+
684
+ let basePx = 18;
685
+ const userFontSize = this.opts.subtitleStyle?.fontSize;
686
+ if (userFontSize) {
687
+ const match = String(userFontSize).match(/([\d.]+)(px|em|rem)?/);
688
+ if (match) {
689
+ const val = parseFloat(match[1]);
690
+ const unit = match[2] || 'px';
691
+ if (unit === 'px') basePx = val;
692
+ else if (unit === 'em' || unit === 'rem') basePx = val * 16;
693
+ }
694
+ }
695
+
696
+ let size = basePx * (w / REFERENCE_WIDTH);
697
+
698
+ if (w < 260 || h < 150) {
699
+ size = Math.min(size, 8);
700
+ }
701
+
702
+ size = Math.max(size, 8);
703
+ size = Math.min(size, basePx);
704
+
705
+ this.overlayText.style.fontSize = size + 'px';
520
706
  }
521
707
 
522
708
  _initDrag(handle, overlay) {
523
709
  const onStart = (e) => {
524
- // Prevent conflicts with player controls
525
710
  e.stopPropagation();
526
711
  const isTouch = e.type === 'touchstart';
527
712
  const clientX = isTouch ? e.touches[0].clientX : e.clientX;
@@ -530,7 +715,6 @@
530
715
  const rect = overlay.getBoundingClientRect();
531
716
  const contRect = this.container.getBoundingClientRect();
532
717
 
533
- // Switch to absolute positioning on first drag
534
718
  if (!this._overlayPos) {
535
719
  const currentLeft = rect.left - contRect.left + rect.width / 2;
536
720
  const currentTop = rect.top - contRect.top + rect.height / 2;
@@ -583,7 +767,9 @@
583
767
  };
584
768
 
585
769
  handle.addEventListener('mousedown', onStart);
770
+ overlay.addEventListener('touchstart', onStart, { passive: false });
586
771
  handle.addEventListener('touchstart', onStart, { passive: false });
772
+
587
773
  this._drag._onEnd = onEnd;
588
774
  }
589
775
 
@@ -646,7 +832,6 @@
646
832
  _createButton() {
647
833
  const position = this.opts.position;
648
834
 
649
- // Button wrapper only (menu is appended directly to body)
650
835
  const wrap = document.createElement('div');
651
836
  wrap.style.cssText = 'position:relative;display:inline-flex;align-items:center;';
652
837
 
@@ -662,13 +847,11 @@
662
847
 
663
848
  wrap.appendChild(btn);
664
849
 
665
- // Menu is appended directly to body — escapes any stacking context from the player
666
850
  const menu = document.createElement('div');
667
851
  menu.className = 'myetv-autosub-menu';
668
852
  menu.addEventListener('click', (e) => e.stopPropagation());
669
853
  document.body.appendChild(menu);
670
854
 
671
- // Insert button into the player DOM
672
855
  if (position === 'topbar') {
673
856
  const settingsControl = this.container.querySelector('.settings-control');
674
857
  if (settingsControl) settingsControl.insertBefore(wrap, settingsControl.firstChild);
@@ -692,7 +875,6 @@
692
875
  this.btnEl = btn;
693
876
  this.menuEl = menu;
694
877
 
695
- // Close menu when clicking outside
696
878
  this._menuOutsideHandler = (e) => {
697
879
  if (!menu.contains(e.target) && e.target !== btn) {
698
880
  this._closeMenuNow();
@@ -700,7 +882,6 @@
700
882
  };
701
883
  document.addEventListener('click', this._menuOutsideHandler);
702
884
 
703
- // Close menu on Escape key
704
885
  this._menuEscHandler = (e) => {
705
886
  if (e.key === 'Escape') this._closeMenuNow();
706
887
  };
@@ -718,10 +899,8 @@
718
899
  return;
719
900
  }
720
901
 
721
- // Rebuild menu content
722
902
  this._buildMenu();
723
903
 
724
- // Position before showing (measure offset first)
725
904
  this.menuEl.style.visibility = 'hidden';
726
905
  this.menuEl.style.display = 'block';
727
906
 
@@ -731,14 +910,12 @@
731
910
  const vpH = window.innerHeight;
732
911
  const vpW = window.innerWidth;
733
912
 
734
- // Open above or below depending on available space
735
913
  const spaceAbove = btnRect.top - 8;
736
914
  const spaceBelow = vpH - btnRect.bottom - 8;
737
915
  let top = (spaceAbove >= menuH || spaceAbove >= spaceBelow)
738
916
  ? btnRect.top - menuH - 8
739
917
  : btnRect.bottom + 8;
740
918
 
741
- // Align to the right edge of the button, clamped to viewport
742
919
  let left = btnRect.right - menuW;
743
920
  left = Math.max(4, Math.min(vpW - menuW - 4, left));
744
921
  top = Math.max(4, Math.min(vpH - menuH - 4, top));
@@ -783,27 +960,29 @@
783
960
  sec1.appendChild(itemOff);
784
961
  this.menuEl.appendChild(sec1);
785
962
 
786
- // ── SECTION 2: GENERATE / PANEL ──────────────────────────────────────
787
- const sec2 = this._menuSection('Management');
963
+ // ── SECTION 2: GENERATE / PANEL (Only if NOT using native imported subs)
964
+ if (!this.isImportedSubs) {
965
+ const sec2 = this._menuSection('Management');
788
966
 
789
- const genLabel = isGenerating ? '⏳ Generating...' : hasSubs ? '🔄 Regenerate transcription' : '🎙️ Generate subtitles';
790
- const itemGen = this._menuItem(genLabel, false, isGenerating, () => {
791
- this._deleteCache();
792
- this.subtitles = [];
793
- this.subtitlesTrans = [];
794
- this.transCache = {};
795
- this.closeMenuNow();
796
- this._generate();
797
- });
967
+ const genLabel = isGenerating ? '⏳ Generating...' : hasSubs ? '🔄 Regenerate transcription' : '🎙️ Generate subtitles';
968
+ const itemGen = this._menuItem(genLabel, false, isGenerating, () => {
969
+ this._deleteCache();
970
+ this.subtitles = [];
971
+ this.subtitlesTrans = [];
972
+ this._transCache = {};
973
+ this._closeMenuNow();
974
+ this.generate();
975
+ });
798
976
 
799
- const itemPanel = this._menuItem('📊 Show progress', false, !isGenerating, () => {
800
- this._closeMenuNow();
801
- this._openPanel();
802
- });
977
+ const itemPanel = this._menuItem('📊 Show progress', false, !isGenerating, () => {
978
+ this._closeMenuNow();
979
+ this._openPanel();
980
+ });
803
981
 
804
- sec2.appendChild(itemGen);
805
- if (isGenerating) sec2.appendChild(itemPanel);
806
- this.menuEl.appendChild(sec2);
982
+ sec2.appendChild(itemGen);
983
+ if (isGenerating) sec2.appendChild(itemPanel);
984
+ this.menuEl.appendChild(sec2);
985
+ }
807
986
 
808
987
  // ── SECTION 3: TRANSLATION ────────────────────────────────────────────
809
988
  if (hasSubs) {
@@ -884,7 +1063,6 @@
884
1063
  return;
885
1064
  }
886
1065
 
887
- // Skip if source language equals target language
888
1066
  const sourceLang = this._resolveLanguage(this.opts.language);
889
1067
  if (sourceLang && sourceLang === langCode.slice(0, 2)) {
890
1068
  if (this.player.options.debug) console.log('[AutoSub] Source = target lang, skipping translation');
@@ -893,7 +1071,6 @@
893
1071
  return;
894
1072
  }
895
1073
 
896
- // Already cached in memory?
897
1074
  if (this._transCache[langCode]) {
898
1075
  this.subtitlesTrans = this._transCache[langCode];
899
1076
  if (this.player.options.debug) console.log('[AutoSub] Translation cache hit:', langCode);
@@ -904,10 +1081,13 @@
904
1081
  this._setBtnGenerating(true);
905
1082
 
906
1083
  try {
907
- // Batch segments to minimize API requests (max ~400 chars per request)
908
1084
  const translated = await this._translateBatch(this.subtitles, langCode);
909
1085
  this.subtitlesTrans = translated;
910
1086
  this._transCache[langCode] = translated;
1087
+
1088
+ // Save translations persistently to cache
1089
+ this._saveToCache(this.subtitles, null, null, this._lastDetectedLang);
1090
+
911
1091
  if (this.player.options.debug) console.log('[AutoSub] Translation done:', translated.length, 'segments');
912
1092
  } catch (err) {
913
1093
  if (this.player.options.debug) console.warn('[AutoSub] Translation error:', err.message);
@@ -921,7 +1101,6 @@
921
1101
  async _translateText(text, sourceLang, targetLang) {
922
1102
  const engine = this.opts.translationEngine;
923
1103
 
924
- // ── MyMemory (default) ──────────────────────────────────────────────
925
1104
  if (!engine || engine.type === 'mymemory') {
926
1105
  const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${sourceLang}|${targetLang}`;
927
1106
  const resp = await fetch(url);
@@ -932,7 +1111,6 @@
932
1111
  return json.responseData?.translatedText ?? text;
933
1112
  }
934
1113
 
935
- // ── LibreTranslate ──────────────────────────────────────────────────
936
1114
  if (engine.type === 'libretranslate') {
937
1115
  const body = { q: text, source: sourceLang, target: targetLang, format: 'text' };
938
1116
  if (engine.apiKey) body.api_key = engine.apiKey;
@@ -947,7 +1125,6 @@
947
1125
  return json.translatedText ?? text;
948
1126
  }
949
1127
 
950
- // ── MarianMT (Argos Translate API / custom REST endpoint) ───────────
951
1128
  if (engine.type === 'marianmt') {
952
1129
  const resp = await fetch(engine.url.replace(/\/$/, '') + '/translate', {
953
1130
  method: 'POST',
@@ -966,7 +1143,6 @@
966
1143
  const SEP = ' ||| ';
967
1144
  const BATCH_CHARS = 400;
968
1145
 
969
- // Detect source language
970
1146
  let sourceLang = null;
971
1147
  if (this.opts.language) {
972
1148
  const lower = this.opts.language.toLowerCase().trim();
@@ -985,7 +1161,6 @@
985
1161
  }
986
1162
  if (!sourceLang) sourceLang = 'en';
987
1163
 
988
- // Normalize target to 2-letter code
989
1164
  const normTarget = LANGUAGE_MAP[targetLang]
990
1165
  ? targetLang
991
1166
  : (Object.entries(LANGUAGE_MAP).find(([k, v]) => v === targetLang)?.[0] ?? targetLang.slice(0, 2));
@@ -999,17 +1174,18 @@
999
1174
 
1000
1175
  const engine = this.opts.translationEngine;
1001
1176
 
1002
- // ── LibreTranslate: array nativo, mapping 1:1 garantito ──────────
1003
1177
  if (engine?.type === 'libretranslate') {
1004
1178
  const BATCH_SIZE = 50;
1005
1179
  const result = [];
1006
- const url = `${engine.url.replace(/\/$/, '')}/translate`;
1180
+
1181
+ let url = engine.url.replace(/\/$/, '');
1182
+ if (!url.endsWith('/translate') && !url.includes('.php')) url += '/translate';
1007
1183
 
1008
1184
  for (let i = 0; i < segments.length; i += BATCH_SIZE) {
1009
1185
  const batch = segments.slice(i, i + BATCH_SIZE);
1010
1186
  try {
1011
1187
  const body = {
1012
- q: batch.map(s => s.text), // array → risposta è array nella stessa posizione
1188
+ q: batch.map(s => s.text),
1013
1189
  source: sourceLang,
1014
1190
  target: normTarget,
1015
1191
  format: 'text'
@@ -1044,7 +1220,6 @@
1044
1220
  return result;
1045
1221
  }
1046
1222
 
1047
- // ── MyMemory fallback ────────────────────────────────────────────
1048
1223
  const batches = [];
1049
1224
  let current = [], charCount = 0;
1050
1225
  for (const seg of segments) {
@@ -1074,13 +1249,13 @@
1074
1249
  return result;
1075
1250
  }
1076
1251
 
1077
-
1078
1252
  // ─────────────────────────────────────────────────────────────────────────
1079
1253
  // PRIVATE — DISPLAY
1080
1254
  // ─────────────────────────────────────────────────────────────────────────
1081
1255
 
1082
1256
  _startDisplay() {
1083
1257
  this._stopDisplay();
1258
+ this._applyResponsiveFontSize();
1084
1259
  this.updateInterval = setInterval(() => this._tick(), 80);
1085
1260
  }
1086
1261
 
@@ -1096,7 +1271,6 @@
1096
1271
  _tick() {
1097
1272
  if (!this.subVisible) return;
1098
1273
 
1099
- // Use translated segments if available, otherwise fall back to originals
1100
1274
  const pool = (this.translateLang !== 'off' && this.subtitlesTrans.length > 0)
1101
1275
  ? this.subtitlesTrans
1102
1276
  : this.subtitles;
@@ -1133,7 +1307,6 @@
1133
1307
 
1134
1308
  _resolveMpdUrl() {
1135
1309
  try {
1136
- // Salvato sul container dal player al momento di initialize()
1137
1310
  const url = this.container.dataset.mpdUrl || this.video.dataset.mpdUrl || null;
1138
1311
  if (url && !url.startsWith('blob:')) return url;
1139
1312
  } catch (_) { }
@@ -1158,7 +1331,6 @@
1158
1331
  if (this.player.options.debug)
1159
1332
  console.log('[AutoSub] _extractAudio — currentSrc:', this.video.currentSrc, '| adaptiveStreamingType:', this.player.adaptiveStreamingType);
1160
1333
 
1161
- // ── DASH: resolve real MPD URL via dash.js instance ───────────────────
1162
1334
  const mpdUrl = this._resolveMpdUrl();
1163
1335
  if (mpdUrl) {
1164
1336
  if (this.player.options.debug) console.log('[AutoSub] DASH mode — mpdUrl:', mpdUrl);
@@ -1169,13 +1341,11 @@
1169
1341
  }
1170
1342
  }
1171
1343
 
1172
- // ── HLS ───────────────────────────────────────────────────────────────
1173
1344
  const src = this.video.currentSrc || this.video.src || '';
1174
1345
  if (src.includes('.m3u8') || this.player.adaptiveStreamingType === 'hls') {
1175
1346
  return await this._extractAudioFromCaptureStream();
1176
1347
  }
1177
1348
 
1178
- // ── Normal MP4 / WebM — direct fetch ─────────────────────────────────
1179
1349
  try { return await this._extractAudioFromFetch(); }
1180
1350
  catch (err) {
1181
1351
  if (this.player.options.debug) console.warn('[AutoSub] Fetch strategy failed:', err.message, '— fallback to captureStream');
@@ -1193,7 +1363,6 @@
1193
1363
  const parser = new DOMParser();
1194
1364
  const mpdDoc = parser.parseFromString(mpdText, 'application/xml');
1195
1365
 
1196
- // Check for XML parse errors
1197
1366
  const parseErr = mpdDoc.querySelector('parsererror');
1198
1367
  if (parseErr) throw new Error('MPD XML parse error: ' + parseErr.textContent.slice(0, 100));
1199
1368
 
@@ -1213,7 +1382,6 @@
1213
1382
 
1214
1383
  this._updatePanel('Downloading audio segments (0/' + segmentUrls.length + ')...', 12);
1215
1384
 
1216
- // Download segments in parallel batches of 5
1217
1385
  const BATCH = 5;
1218
1386
  const segmentBuffers = new Array(segmentUrls.length);
1219
1387
 
@@ -1233,7 +1401,6 @@
1233
1401
  );
1234
1402
  }
1235
1403
 
1236
- // Concatenate all segment buffers into one
1237
1404
  this._updatePanel('Decoding audio...', 30);
1238
1405
  const totalBytes = segmentBuffers.reduce((s, b) => s + b.byteLength, 0);
1239
1406
  const combined = new Uint8Array(totalBytes);
@@ -1269,13 +1436,11 @@
1269
1436
 
1270
1437
  _findAudioAdaptationSet(mpdDoc) {
1271
1438
  const sets = Array.from(mpdDoc.querySelectorAll('AdaptationSet'));
1272
- // Prefer explicit audio contentType or mimeType
1273
1439
  return sets.find(s =>
1274
1440
  s.getAttribute('contentType') === 'audio' ||
1275
1441
  (s.getAttribute('mimeType') || '').startsWith('audio') ||
1276
1442
  s.querySelector('Representation[mimeType^="audio"]') !== null
1277
1443
  ) || sets.find(s =>
1278
- // Fallback: AdaptationSet that is not video
1279
1444
  !(s.getAttribute('mimeType') || '').startsWith('video') &&
1280
1445
  s.getAttribute('contentType') !== 'video'
1281
1446
  );
@@ -1294,7 +1459,6 @@
1294
1459
  _buildSegmentUrlList(mpdDoc, adaptationSet, representation, baseUrl) {
1295
1460
  const urls = [];
1296
1461
 
1297
- // ── SegmentList ──────────────────────────────────────────────────────
1298
1462
  const segList = representation.querySelector('SegmentList')
1299
1463
  || adaptationSet.querySelector('SegmentList');
1300
1464
  if (segList) {
@@ -1310,7 +1474,6 @@
1310
1474
  return urls;
1311
1475
  }
1312
1476
 
1313
- // ── SegmentTemplate ──────────────────────────────────────────────────
1314
1477
  const segTpl = representation.querySelector('SegmentTemplate')
1315
1478
  || adaptationSet.querySelector('SegmentTemplate');
1316
1479
  if (segTpl) {
@@ -1345,7 +1508,6 @@
1345
1508
  return urls;
1346
1509
  }
1347
1510
 
1348
- // ── BaseURL (single audio file) ───────────────────────────────────────
1349
1511
  const baseURLEl = representation.querySelector('BaseURL')
1350
1512
  || adaptationSet.querySelector('BaseURL');
1351
1513
  if (baseURLEl) {
@@ -1363,7 +1525,6 @@
1363
1525
  }
1364
1526
 
1365
1527
  _parseDuration(iso) {
1366
- // Parse ISO 8601 duration: PT1H2M3.5S
1367
1528
  const m = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?/);
1368
1529
  if (!m) return 0;
1369
1530
  return (parseFloat(m[1] || 0) * 3600) +
@@ -1437,7 +1598,6 @@
1437
1598
  if (e.data.size === 0) return;
1438
1599
  chunkQueue.push(e.data);
1439
1600
 
1440
- // Estimate accumulated seconds using rough bitrate of 16 kbps (opus)
1441
1601
  const totalSize = chunkQueue.reduce((s, b) => s + b.size, 0);
1442
1602
  const estimatedSec = totalSize / (16000 / 8);
1443
1603
 
@@ -1451,7 +1611,6 @@
1451
1611
  };
1452
1612
 
1453
1613
  recorder.onstop = async () => {
1454
- // Flush any remaining audio as the last chunk
1455
1614
  if (chunkQueue.length > 0 && this._onAudioChunkReady) {
1456
1615
  const blob = new Blob([...chunkQueue], { type: recorder.mimeType || 'audio/webm' });
1457
1616
  chunkQueue.length = 0;
@@ -1463,7 +1622,6 @@
1463
1622
 
1464
1623
  recorder.onerror = e => reject(e.error || new Error('MediaRecorder error'));
1465
1624
 
1466
- // Update capture progress based on video currentTime
1467
1625
  const duration = isFinite(this.video.duration) && this.video.duration > 0
1468
1626
  ? this.video.duration : null;
1469
1627
 
@@ -1487,10 +1645,8 @@
1487
1645
 
1488
1646
  this.video.addEventListener('ended', stop, { once: true });
1489
1647
 
1490
- // Slice every 2s so ondataavailable fires frequently
1491
1648
  recorder.start(2000);
1492
1649
 
1493
- // Resolve immediately with sentinel — actual transcription flows via callbacks
1494
1650
  resolve({ _captureMode: true, stop });
1495
1651
  });
1496
1652
  }
@@ -1544,14 +1700,12 @@ self.onmessage = async function(e) {
1544
1700
  const opts = { return_timestamps: true, task: 'transcribe', chunk_length_s: payload.chunkSec };
1545
1701
  if (payload.lang) opts.language = payload.lang;
1546
1702
  const result = await transcriber(audio, opts);
1547
- // Include detected language in the response so the main thread can reuse it
1548
1703
  const detectedLang = result.language || payload.lang || null;
1549
1704
  self.postMessage({ type: 'chunk_done', payload: { result, timeOffset: payload.timeOffset, chunkIndex: payload.chunkIndex, totalChunks: payload.totalChunks, isLast: payload.isLast, detectedLang } });
1550
1705
  } catch(err) {
1551
1706
  self.postMessage({ type: 'error', payload: err.message || String(err) });
1552
1707
  }
1553
- }
1554
-
1708
+ }
1555
1709
  };
1556
1710
  `;
1557
1711
 
@@ -1578,8 +1732,6 @@ self.onmessage = async function(e) {
1578
1732
  }
1579
1733
 
1580
1734
  async _transcribeChunksStreaming(audioData, partialCache = null) {
1581
-
1582
- // ── CAPTURE STREAM MODE (HLS or DASH fallback) ───────────────────────
1583
1735
  if (audioData?._captureMode) {
1584
1736
  const SAMPLE_RATE = 16000;
1585
1737
  const lang = this._resolveLanguage(this.opts.language);
@@ -1587,7 +1739,6 @@ self.onmessage = async function(e) {
1587
1739
 
1588
1740
  if (!this.subVisible) { this.subVisible = true; this._startDisplay(); }
1589
1741
 
1590
- // Worker starts in parallel while audio is still being captured
1591
1742
  const worker = this._createTranscriberWorker();
1592
1743
  this._worker = worker;
1593
1744
 
@@ -1716,7 +1867,6 @@ self.onmessage = async function(e) {
1716
1867
  return promise;
1717
1868
  }
1718
1869
 
1719
- // ── NORMAL MODE (Float32Array from fetch or DASH segment download) ────
1720
1870
  const float32Audio = audioData;
1721
1871
  const SAMPLE_RATE = 16000;
1722
1872
  const CHUNK_SEC = 30;
@@ -1726,7 +1876,6 @@ self.onmessage = async function(e) {
1726
1876
  let detectedLang = lang || partialCache?.detectedLang || null;
1727
1877
  const modelId = WHISPER_MODELS[this.opts.modelSize] || WHISPER_MODELS.base;
1728
1878
 
1729
- // Use partialCache passed from generate() — no second localStorage read
1730
1879
  const startChunk = (partialCache?.chunksCompleted != null && partialCache.chunksCompleted < totalChunks)
1731
1880
  ? partialCache.chunksCompleted
1732
1881
  : 0;
@@ -1766,7 +1915,6 @@ self.onmessage = async function(e) {
1766
1915
  if (type === 'chunk_done') {
1767
1916
  const { result, timeOffset, chunkIndex } = payload;
1768
1917
 
1769
- // On first processed chunk, save the detected language and reuse it for all subsequent chunks
1770
1918
  if (chunkIndex === startChunk && payload.detectedLang && !detectedLang) {
1771
1919
  detectedLang = payload.detectedLang;
1772
1920
  if (this.player.options.debug) console.log('[AutoSub] Language auto-detected:', detectedLang);
@@ -1788,20 +1936,21 @@ self.onmessage = async function(e) {
1788
1936
  const normalized = this._normalizeChunks(rawChunks);
1789
1937
  this.subtitles.push(...normalized);
1790
1938
 
1791
- // Save partial progress after every chunk
1792
1939
  this._saveToCache(this.subtitles, chunkIndex + 1, totalChunks, detectedLang);
1793
1940
 
1794
- // If translation is active, translate this chunk immediately and append
1795
1941
  if (this.translateLang && this.translateLang !== 'off' && normalized.length > 0) {
1796
1942
  const currentLang = this.translateLang;
1797
1943
  this._translateBatch(normalized, currentLang).then(translated => {
1798
- // Only append if translation lang hasn't changed in the meantime
1799
1944
  if (this.translateLang === currentLang) {
1800
1945
  this.subtitlesTrans.push(...translated);
1801
1946
  this.subtitlesTrans.sort((a, b) => a.start - b.start);
1947
+
1948
+ if (!this._transCache[currentLang]) this._transCache[currentLang] = [];
1949
+ this._transCache[currentLang].push(...translated);
1950
+ this._transCache[currentLang].sort((a, b) => a.start - b.start);
1951
+ this._saveToCache(this.subtitles, chunkIndex + 1, totalChunks, detectedLang);
1802
1952
  }
1803
1953
  }).catch(() => {
1804
- // On error push originals so display doesn't go blank
1805
1954
  if (this.translateLang === currentLang) {
1806
1955
  this.subtitlesTrans.push(...normalized);
1807
1956
  this.subtitlesTrans.sort((a, b) => a.start - b.start);
@@ -1814,17 +1963,13 @@ self.onmessage = async function(e) {
1814
1963
  if (next < totalChunks) {
1815
1964
  const pct = 64 + Math.round((next / totalChunks) * 33);
1816
1965
  this._updatePanel('Transcribing chunk ' + (next + 1) + ' of ' + totalChunks + '...', pct);
1817
- // Use detectedLang instead of lang so all chunks use the same detected language
1818
1966
  this._sendChunk(worker, float32Audio, next, chunkSize, totalChunks, detectedLang, CHUNK_SEC);
1819
1967
  } else {
1820
1968
  if (this.player.options.debug) console.log('[AutoSub] All chunks done, subtitles:', this.subtitles.length);
1821
1969
  worker.terminate(); this._worker = null;
1822
1970
  resolve();
1823
- // generate() chiamerà _saveToCache(subtitles, null, null, detectedLang)
1824
- // ma detectedLang non è accessibile lì — salvalo su this
1825
1971
  this._lastDetectedLang = detectedLang;
1826
1972
  }
1827
-
1828
1973
  }
1829
1974
 
1830
1975
  if (type === 'error') {
@@ -1881,7 +2026,6 @@ self.onmessage = async function(e) {
1881
2026
  const safe = String(this.opts.idCache).replace(/[^a-zA-Z0-9_-]/g, '_');
1882
2027
  return CACHE_PREFIX + safe;
1883
2028
  }
1884
- // Use the MPD URL or player.options.src as stable key — currentSrc may be a blob URL
1885
2029
  const src = this._resolveMpdUrl()
1886
2030
  || this.player.options?.src
1887
2031
  || this.video.currentSrc
@@ -1909,7 +2053,9 @@ self.onmessage = async function(e) {
1909
2053
  subtitles: parsed.subtitles,
1910
2054
  chunksCompleted: parsed.chunksCompleted ?? null,
1911
2055
  totalChunks: parsed.totalChunks ?? null,
1912
- detectedLang: parsed.detectedLang ?? null // ← aggiunto
2056
+ detectedLang: parsed.detectedLang ?? null,
2057
+ transCache: parsed.transCache || {},
2058
+ isImportedSubs: parsed.isImportedSubs || false
1913
2059
  };
1914
2060
  } catch {
1915
2061
  return null;
@@ -1925,6 +2071,8 @@ self.onmessage = async function(e) {
1925
2071
  chunksCompleted,
1926
2072
  totalChunks,
1927
2073
  detectedLang,
2074
+ transCache: this._transCache || {},
2075
+ isImportedSubs: this.isImportedSubs || false,
1928
2076
  savedAt: Date.now()
1929
2077
  });
1930
2078
  try {
@@ -1980,4 +2128,4 @@ self.onmessage = async function(e) {
1980
2128
  };
1981
2129
 
1982
2130
  register();
1983
- })();
2131
+ })();