myetv-player 1.7.0 → 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); }
@@ -220,7 +223,14 @@
220
223
  `;
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
- const ICON_LOADING = `<svg viewBox="0 0 24 24"><path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z"><animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/></path></svg>`;
226
+ const ICON_LOADING = `<svg viewBox="0 0 24 24">
227
+ <path d="M12 2A10 10 0 0 1 22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none">
228
+ <animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>
229
+ </path>
230
+ <g transform="translate(4, 5) scale(0.65)">
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"/>
232
+ </g>
233
+ </svg>`;
224
234
 
225
235
  const WHISPER_MODELS = {
226
236
  tiny: 'Xenova/whisper-tiny',
@@ -284,19 +294,25 @@
284
294
  modelSize: 'base',
285
295
  autoGenerate: false,
286
296
  showButton: true,
287
- position: 'right', // 'left' | 'right' | 'topbar'
297
+ position: 'right',
288
298
  subtitleStyle: {},
289
299
  cacheEnabled: true,
290
300
  idCache: null,
291
- autoTranslation: null, // ISO 2-letter code, e.g. 'en', 'it'
301
+ autoTranslation: null,
302
+ translationEngine: null,
292
303
  }, options);
293
304
 
294
305
  this.isGenerating = false;
295
- this.subtitles = []; // { start, end, text } original segments
296
- this.subtitlesTrans = []; // { start, end, text } translated segments (in-memory cache)
297
- this.translateLang = 'off'; // currently active translation language
298
- 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: [...] }
299
310
  this.subVisible = false;
311
+
312
+ // Flags for Native Subtitle Integration
313
+ this.hasNativeTracks = false;
314
+ this.isImportedSubs = false;
315
+
300
316
  this.updateInterval = null;
301
317
  this.btnWrapEl = null;
302
318
  this.btnEl = null;
@@ -307,7 +323,7 @@
307
323
  this._worker = null;
308
324
  // Drag state
309
325
  this._drag = { active: false, startX: 0, startY: 0, origLeft: 0, origTop: 0 };
310
- // Overlay position (absolute px, or null → uses default bottom:72px from CSS)
326
+ // Overlay position
311
327
  this._overlayPos = null;
312
328
  }
313
329
 
@@ -316,94 +332,263 @@
316
332
  this._createOverlay();
317
333
  this._createPanel();
318
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
+
319
360
  if (this.opts.autoTranslation) {
320
361
  this._scheduleAutoTranslation();
321
362
  }
363
+
322
364
  if (this.opts.autoGenerate) {
323
- 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 });
324
371
  }
372
+
325
373
  if (this.player.options.debug) console.log('[AutoSub] Plugin setup complete');
326
374
  }
327
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
+
328
433
  _scheduleAutoTranslation() {
329
- // Light polling: waits until this.subtitles is populated (from cache or transcription),
330
- // then triggers translation automatically
331
434
  const lang = this.opts.autoTranslation.toLowerCase().trim().slice(0, 2);
332
- let tries = 0;
435
+ this.translateLang = lang;
333
436
 
437
+ let tries = 0;
334
438
  const check = setInterval(() => {
335
439
  tries++;
336
- if (this.subtitles.length > 0) {
440
+ // Wait until transcription/import is fully complete
441
+ if (this.subtitles.length > 0 && !this.isGenerating) {
337
442
  clearInterval(check);
338
- if (this.player.options.debug)
339
- console.log('[AutoSub] autoTranslation → triggering lang:', lang);
443
+ if (this.player.options.debug) console.log('[AutoSub] autoTranslation final pass, lang:', lang);
444
+
445
+ this.subtitlesTrans = [];
446
+ // Note: We DO NOT clear this._transCache here, to preserve cached translations!
340
447
  this._setTranslationLang(lang);
341
448
  this._updateMenu();
342
449
  return;
343
450
  }
344
- // Stop waiting after 5 minutes (enough for any transcription, even large videos with slow models)
345
451
  if (tries > 300) clearInterval(check);
346
452
  }, 1000);
347
453
  }
348
454
 
349
455
  async handleButtonClick() {
350
- // 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
351
491
  if (this.subtitles.length === 0 && !this.isGenerating) {
352
492
  const cached = this._loadFromCache();
353
493
  if (cached) {
354
- 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
+
355
506
  this.subVisible = true;
356
507
  this._startDisplay();
357
508
  this._setBtnActive(true);
358
509
  this._updateMenu();
510
+
511
+ if (this.translateLang && this.translateLang !== 'off') {
512
+ this._setTranslationLang(this.translateLang);
513
+ }
514
+
359
515
  if (this.player.options.debug)
360
- console.log('[AutoSub] Loaded from cache:', cached.length, 'segments');
361
- // Open menu so the user can see current state and translation options
516
+ console.log('[AutoSub] Loaded from cache:', cached.subtitles.length, 'segments');
517
+
362
518
  this._toggleMenu();
363
519
  return;
364
520
  }
365
521
  }
366
- // No cache found, or subtitles already loaded — just toggle the menu
522
+
523
+ // Default: Toggle the menu
367
524
  this._toggleMenu();
368
525
  }
369
526
 
370
527
  async generate() {
371
- if (this.isGenerating) return;
528
+ // Block generation if we are already using native imported subtitles
529
+ if (this.isGenerating || this.isImportedSubs) return;
530
+
531
+ let partialCache = null;
372
532
 
373
- // Check cache before doing anything
374
533
  if (this.subtitles.length === 0) {
375
534
  const cached = this._loadFromCache();
376
535
  if (cached) {
377
- this.subtitles = cached;
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
+
378
548
  this.subVisible = true;
379
549
  this._startDisplay();
380
550
  this._setBtnActive(true);
381
551
  this._updateMenu();
552
+
553
+ if (cached.chunksCompleted === null || cached.chunksCompleted >= cached.totalChunks) {
554
+ if (this.player.options.debug) console.log('[AutoSub] Full cache hit, skipping transcription');
555
+ return;
556
+ }
557
+
558
+ partialCache = cached;
382
559
  if (this.player.options.debug)
383
- console.log('[AutoSub] generate() — cache hit, skipping transcription');
384
- return;
560
+ console.log('[AutoSub] Partial cache found — resuming from chunk', cached.chunksCompleted, 'of', cached.totalChunks);
385
561
  }
386
562
  }
387
563
 
388
564
  this.isGenerating = true;
389
565
  this._setBtnGenerating(true);
390
566
  this._updatePanel('Extracting audio...', 5);
391
-
392
567
  try {
393
568
  const audioData = await this._extractAudio();
394
569
  this._updatePanel('Downloading Transformers.js bundle...', 30);
395
570
  await this._ensureBundle();
396
571
  this._updatePanel('Preparing Whisper worker...', 38);
397
- await this._transcribeChunksStreaming(audioData);
398
- this._saveToCache(this.subtitles);
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);
399
577
  this._updatePanel('Subtitles ready!', 100);
400
- setTimeout(() => {
401
- this._closePanel();
402
- this._setBtnActive(true);
403
- this._updateMenu();
404
- if (this.player.options.debug)
405
- console.log('[AutoSub] Generation complete:', this.subtitles.length, 'segments');
406
- }, 800);
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);
587
+ }
588
+
589
+ setTimeout(() => { this._closePanel(); }, 800);
590
+
591
+ if (this.player.options.debug) console.log('[AutoSub] Generation complete:', this.subtitles.length, 'segments');
407
592
  } catch (err) {
408
593
  if (this.player.options.debug) console.error('[AutoSub] Generation error:', err);
409
594
  this._updatePanel('Error: ' + (err.message || String(err)), 0);
@@ -423,6 +608,10 @@
423
608
  dispose() {
424
609
  this._stopDisplay();
425
610
  this._destroyDrag();
611
+ if (this._resizeObserver) {
612
+ this._resizeObserver.disconnect();
613
+ this._resizeObserver = null;
614
+ }
426
615
  if (this._captureTimer) { clearTimeout(this._captureTimer); this._captureTimer = null; }
427
616
  if (this._captureProgressInterval) { clearInterval(this._captureProgressInterval); this._captureProgressInterval = null; }
428
617
  if (this._worker) { this._worker.terminate(); this._worker = null; }
@@ -461,7 +650,10 @@
461
650
 
462
651
  const text = document.createElement('div');
463
652
  text.className = 'myetv-autosub-text';
464
- 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);
465
657
 
466
658
  overlay.appendChild(handle);
467
659
  overlay.appendChild(text);
@@ -471,11 +663,50 @@
471
663
  this.overlayText = text;
472
664
 
473
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';
474
706
  }
475
707
 
476
708
  _initDrag(handle, overlay) {
477
709
  const onStart = (e) => {
478
- // Prevent conflicts with player controls
479
710
  e.stopPropagation();
480
711
  const isTouch = e.type === 'touchstart';
481
712
  const clientX = isTouch ? e.touches[0].clientX : e.clientX;
@@ -484,7 +715,6 @@
484
715
  const rect = overlay.getBoundingClientRect();
485
716
  const contRect = this.container.getBoundingClientRect();
486
717
 
487
- // Switch to absolute positioning on first drag
488
718
  if (!this._overlayPos) {
489
719
  const currentLeft = rect.left - contRect.left + rect.width / 2;
490
720
  const currentTop = rect.top - contRect.top + rect.height / 2;
@@ -537,7 +767,9 @@
537
767
  };
538
768
 
539
769
  handle.addEventListener('mousedown', onStart);
770
+ overlay.addEventListener('touchstart', onStart, { passive: false });
540
771
  handle.addEventListener('touchstart', onStart, { passive: false });
772
+
541
773
  this._drag._onEnd = onEnd;
542
774
  }
543
775
 
@@ -600,7 +832,6 @@
600
832
  _createButton() {
601
833
  const position = this.opts.position;
602
834
 
603
- // Button wrapper only (menu is appended directly to body)
604
835
  const wrap = document.createElement('div');
605
836
  wrap.style.cssText = 'position:relative;display:inline-flex;align-items:center;';
606
837
 
@@ -616,13 +847,11 @@
616
847
 
617
848
  wrap.appendChild(btn);
618
849
 
619
- // Menu is appended directly to body — escapes any stacking context from the player
620
850
  const menu = document.createElement('div');
621
851
  menu.className = 'myetv-autosub-menu';
622
852
  menu.addEventListener('click', (e) => e.stopPropagation());
623
853
  document.body.appendChild(menu);
624
854
 
625
- // Insert button into the player DOM
626
855
  if (position === 'topbar') {
627
856
  const settingsControl = this.container.querySelector('.settings-control');
628
857
  if (settingsControl) settingsControl.insertBefore(wrap, settingsControl.firstChild);
@@ -646,7 +875,6 @@
646
875
  this.btnEl = btn;
647
876
  this.menuEl = menu;
648
877
 
649
- // Close menu when clicking outside
650
878
  this._menuOutsideHandler = (e) => {
651
879
  if (!menu.contains(e.target) && e.target !== btn) {
652
880
  this._closeMenuNow();
@@ -654,7 +882,6 @@
654
882
  };
655
883
  document.addEventListener('click', this._menuOutsideHandler);
656
884
 
657
- // Close menu on Escape key
658
885
  this._menuEscHandler = (e) => {
659
886
  if (e.key === 'Escape') this._closeMenuNow();
660
887
  };
@@ -672,10 +899,8 @@
672
899
  return;
673
900
  }
674
901
 
675
- // Rebuild menu content
676
902
  this._buildMenu();
677
903
 
678
- // Position before showing (measure offset first)
679
904
  this.menuEl.style.visibility = 'hidden';
680
905
  this.menuEl.style.display = 'block';
681
906
 
@@ -685,14 +910,12 @@
685
910
  const vpH = window.innerHeight;
686
911
  const vpW = window.innerWidth;
687
912
 
688
- // Open above or below depending on available space
689
913
  const spaceAbove = btnRect.top - 8;
690
914
  const spaceBelow = vpH - btnRect.bottom - 8;
691
915
  let top = (spaceAbove >= menuH || spaceAbove >= spaceBelow)
692
916
  ? btnRect.top - menuH - 8
693
917
  : btnRect.bottom + 8;
694
918
 
695
- // Align to the right edge of the button, clamped to viewport
696
919
  let left = btnRect.right - menuW;
697
920
  left = Math.max(4, Math.min(vpW - menuW - 4, left));
698
921
  top = Math.max(4, Math.min(vpH - menuH - 4, top));
@@ -737,26 +960,29 @@
737
960
  sec1.appendChild(itemOff);
738
961
  this.menuEl.appendChild(sec1);
739
962
 
740
- // ── SECTION 2: GENERATE / PANEL ──────────────────────────────────────
741
- 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');
742
966
 
743
- const genLabel = isGenerating ? 'ā³ Generating...' : hasSubs ? 'šŸ”„ Regenerate transcription' : 'šŸŽ™ļø Generate subtitles';
744
- const itemGen = this._menuItem(genLabel, false, isGenerating, () => {
745
- this.subtitles = [];
746
- this.subtitlesTrans = [];
747
- this._transCache = {};
748
- this._closeMenuNow();
749
- this.generate();
750
- });
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
+ });
751
976
 
752
- const itemPanel = this._menuItem('šŸ“Š Show progress', false, !isGenerating, () => {
753
- this._closeMenuNow();
754
- this._openPanel();
755
- });
977
+ const itemPanel = this._menuItem('šŸ“Š Show progress', false, !isGenerating, () => {
978
+ this._closeMenuNow();
979
+ this._openPanel();
980
+ });
756
981
 
757
- sec2.appendChild(itemGen);
758
- if (isGenerating) sec2.appendChild(itemPanel);
759
- this.menuEl.appendChild(sec2);
982
+ sec2.appendChild(itemGen);
983
+ if (isGenerating) sec2.appendChild(itemPanel);
984
+ this.menuEl.appendChild(sec2);
985
+ }
760
986
 
761
987
  // ── SECTION 3: TRANSLATION ────────────────────────────────────────────
762
988
  if (hasSubs) {
@@ -831,14 +1057,20 @@
831
1057
 
832
1058
  async _setTranslationLang(langCode) {
833
1059
  this.translateLang = langCode;
834
-
835
1060
  if (langCode === 'off') {
836
1061
  this.subtitlesTrans = [];
837
1062
  if (this.player.options.debug) console.log('[AutoSub] Translation disabled');
838
1063
  return;
839
1064
  }
840
1065
 
841
- // Already cached in memory?
1066
+ const sourceLang = this._resolveLanguage(this.opts.language);
1067
+ if (sourceLang && sourceLang === langCode.slice(0, 2)) {
1068
+ if (this.player.options.debug) console.log('[AutoSub] Source = target lang, skipping translation');
1069
+ this.subtitlesTrans = [];
1070
+ this.translateLang = 'off';
1071
+ return;
1072
+ }
1073
+
842
1074
  if (this._transCache[langCode]) {
843
1075
  this.subtitlesTrans = this._transCache[langCode];
844
1076
  if (this.player.options.debug) console.log('[AutoSub] Translation cache hit:', langCode);
@@ -849,10 +1081,13 @@
849
1081
  this._setBtnGenerating(true);
850
1082
 
851
1083
  try {
852
- // Batch segments to minimize API requests (max ~400 chars per request)
853
1084
  const translated = await this._translateBatch(this.subtitles, langCode);
854
1085
  this.subtitlesTrans = translated;
855
1086
  this._transCache[langCode] = translated;
1087
+
1088
+ // Save translations persistently to cache
1089
+ this._saveToCache(this.subtitles, null, null, this._lastDetectedLang);
1090
+
856
1091
  if (this.player.options.debug) console.log('[AutoSub] Translation done:', translated.length, 'segments');
857
1092
  } catch (err) {
858
1093
  if (this.player.options.debug) console.warn('[AutoSub] Translation error:', err.message);
@@ -863,61 +1098,133 @@
863
1098
  }
864
1099
  }
865
1100
 
1101
+ async _translateText(text, sourceLang, targetLang) {
1102
+ const engine = this.opts.translationEngine;
1103
+
1104
+ if (!engine || engine.type === 'mymemory') {
1105
+ const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${sourceLang}|${targetLang}`;
1106
+ const resp = await fetch(url);
1107
+ const json = await resp.json();
1108
+ if (json.responseStatus && json.responseStatus !== 200) {
1109
+ throw new Error('MyMemory error: ' + json.responseDetails + ' (status ' + json.responseStatus + ')');
1110
+ }
1111
+ return json.responseData?.translatedText ?? text;
1112
+ }
1113
+
1114
+ if (engine.type === 'libretranslate') {
1115
+ const body = { q: text, source: sourceLang, target: targetLang, format: 'text' };
1116
+ if (engine.apiKey) body.api_key = engine.apiKey;
1117
+ const resp = await fetch(engine.url.replace(/\/$/, '') + '/translate', {
1118
+ method: 'POST',
1119
+ headers: { 'Content-Type': 'application/json' },
1120
+ body: JSON.stringify(body)
1121
+ });
1122
+ if (!resp.ok) throw new Error('LibreTranslate HTTP ' + resp.status);
1123
+ const json = await resp.json();
1124
+ if (json.error) throw new Error('LibreTranslate: ' + json.error);
1125
+ return json.translatedText ?? text;
1126
+ }
1127
+
1128
+ if (engine.type === 'marianmt') {
1129
+ const resp = await fetch(engine.url.replace(/\/$/, '') + '/translate', {
1130
+ method: 'POST',
1131
+ headers: { 'Content-Type': 'application/json' },
1132
+ body: JSON.stringify({ q: text, source: sourceLang, target: targetLang })
1133
+ });
1134
+ if (!resp.ok) throw new Error('MarianMT HTTP ' + resp.status);
1135
+ const json = await resp.json();
1136
+ return json.translatedText ?? json.translation ?? text;
1137
+ }
1138
+
1139
+ throw new Error('Unknown translation engine: ' + engine.type);
1140
+ }
1141
+
866
1142
  async _translateBatch(segments, targetLang) {
1143
+ const SEP = ' ||| ';
867
1144
  const BATCH_CHARS = 400;
868
- const SEP = '\n||||\n';
869
1145
 
870
- // ── Detect source language ────────────────────────────────────────────
871
- // 1. Use the language set in plugin options (if provided)
872
- // 2. Otherwise try to detect it from the first segment via MyMemory
873
- // 3. Absolute fallback: 'en'
874
1146
  let sourceLang = null;
875
-
876
1147
  if (this.opts.language) {
877
- // Normalize: 'italian' → 'it', 'it' → 'it'
878
1148
  const lower = this.opts.language.toLowerCase().trim();
879
- // Reverse lookup in LANGUAGE_MAP (value → key)
880
1149
  const found = Object.entries(LANGUAGE_MAP).find(([k, v]) => k === lower || v === lower);
881
- sourceLang = found ? found[0] : lower.slice(0, 2); // fallback: first 2 chars
1150
+ sourceLang = found ? found[0] : lower.slice(0, 2);
882
1151
  }
883
-
884
1152
  if (!sourceLang && segments.length > 0) {
885
- // Try to detect language from the first segment via MyMemory
886
1153
  try {
887
1154
  const sample = segments[0].text.slice(0, 100);
888
1155
  const detectUrl = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(sample)}&langpair=en|en`;
889
1156
  const resp = await fetch(detectUrl);
890
1157
  const json = await resp.json();
891
- // MyMemory returns the detected language in responseData
892
1158
  const detected = json.responseData?.detectedLanguage;
893
- if (detected && /^[a-z]{2}(-[A-Z]{2})?$/.test(detected)) {
894
- sourceLang = detected.slice(0, 2);
895
- }
896
- } catch (_) { }
1159
+ if (/[a-z]{2}-[A-Z]{2}/.test(detected)) sourceLang = detected.slice(0, 2);
1160
+ } catch { }
897
1161
  }
1162
+ if (!sourceLang) sourceLang = 'en';
898
1163
 
899
- if (!sourceLang) sourceLang = 'en'; // absolute fallback
1164
+ const normTarget = LANGUAGE_MAP[targetLang]
1165
+ ? targetLang
1166
+ : (Object.entries(LANGUAGE_MAP).find(([k, v]) => v === targetLang)?.[0] ?? targetLang.slice(0, 2));
900
1167
 
901
- // Skip translation if source and target language are the same
902
- if (sourceLang === targetLang) {
903
- if (this.player.options.debug)
904
- console.log('[AutoSub] Source === target lang, skipping translation');
1168
+ if (sourceLang === normTarget) {
1169
+ if (this.player.options.debug) console.log('[AutoSub] Source = target lang, skipping translation');
905
1170
  return segments.map(s => ({ ...s }));
906
1171
  }
907
1172
 
908
- if (this.player.options.debug)
909
- console.log('[AutoSub] Translating', sourceLang, '→', targetLang);
1173
+ if (this.player.options.debug) console.log('[AutoSub] Translating', sourceLang, '→', normTarget);
910
1174
 
911
- // ── Batching ──────────────────────────────────────────────────────────
912
- const batches = [];
913
- let current = [];
914
- let charCount = 0;
1175
+ const engine = this.opts.translationEngine;
1176
+
1177
+ if (engine?.type === 'libretranslate') {
1178
+ const BATCH_SIZE = 50;
1179
+ const result = [];
1180
+
1181
+ let url = engine.url.replace(/\/$/, '');
1182
+ if (!url.endsWith('/translate') && !url.includes('.php')) url += '/translate';
1183
+
1184
+ for (let i = 0; i < segments.length; i += BATCH_SIZE) {
1185
+ const batch = segments.slice(i, i + BATCH_SIZE);
1186
+ try {
1187
+ const body = {
1188
+ q: batch.map(s => s.text),
1189
+ source: sourceLang,
1190
+ target: normTarget,
1191
+ format: 'text'
1192
+ };
1193
+ if (engine.apiKey) body.api_key = engine.apiKey;
1194
+
1195
+ const resp = await fetch(url, {
1196
+ method: 'POST',
1197
+ headers: { 'Content-Type': 'application/json' },
1198
+ body: JSON.stringify(body)
1199
+ });
1200
+ if (!resp.ok) throw new Error(`LibreTranslate HTTP ${resp.status}`);
1201
+ const json = await resp.json();
1202
+ if (json.error) throw new Error(`LibreTranslate: ${json.error}`);
1203
+
1204
+ const translations = Array.isArray(json.translatedText)
1205
+ ? json.translatedText
1206
+ : [json.translatedText];
1207
+
1208
+ batch.forEach((seg, j) => result.push({
1209
+ start: seg.start,
1210
+ end: seg.end,
1211
+ text: translations[j] ?? seg.text
1212
+ }));
1213
+ } catch (err) {
1214
+ if (this.player.options.debug) console.warn('[AutoSub] LibreTranslate batch error', err.message);
1215
+ batch.forEach(seg => result.push({ ...seg }));
1216
+ }
1217
+ if (i + BATCH_SIZE < segments.length)
1218
+ await new Promise(r => setTimeout(r, 100));
1219
+ }
1220
+ return result;
1221
+ }
915
1222
 
1223
+ const batches = [];
1224
+ let current = [], charCount = 0;
916
1225
  for (const seg of segments) {
917
1226
  if (charCount + seg.text.length > BATCH_CHARS && current.length > 0) {
918
- batches.push(current);
919
- current = [];
920
- charCount = 0;
1227
+ batches.push(current); current = []; charCount = 0;
921
1228
  }
922
1229
  current.push(seg);
923
1230
  charCount += seg.text.length;
@@ -927,27 +1234,17 @@
927
1234
  const result = [];
928
1235
  for (const batch of batches) {
929
1236
  const sourceText = batch.map(s => s.text).join(SEP);
930
- const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(sourceText)}&langpair=${sourceLang}|${targetLang}`;
931
1237
  try {
932
- const resp = await fetch(url);
933
- const json = await resp.json();
934
-
935
- // Handle explicit API errors
936
- if (json.responseStatus && json.responseStatus !== 200) {
937
- if (this.player.options.debug) console.warn('[AutoSub] MyMemory API error:', json.responseDetails || json.responseStatus);
938
- batch.forEach(seg => result.push({ ...seg }));
939
- continue;
940
- }
941
-
942
- const translated = json.responseData?.translatedText || sourceText;
943
- const parts = translated.split('||||').map(s => s.trim().replace(/^\n+|\n+$/g, ''));
944
- batch.forEach((seg, i) => {
945
- result.push({ start: seg.start, end: seg.end, text: parts[i] || seg.text });
946
- });
947
- await new Promise(r => setTimeout(r, 120)); // rate-limit delay between batches
948
- } catch (_) {
949
- batch.forEach(seg => result.push({ ...seg })); // fallback: keep original text
1238
+ const translated = await this._translateText(sourceText, sourceLang, normTarget);
1239
+ const parts = translated.split(SEP).map(s => s.trim().replace(/&amp;/g, '&'));
1240
+ batch.forEach((seg, i) => result.push({
1241
+ start: seg.start, end: seg.end, text: parts[i] ?? seg.text
1242
+ }));
1243
+ } catch (err) {
1244
+ if (this.player.options.debug) console.warn('[AutoSub] Translation batch error', err.message);
1245
+ batch.forEach(seg => result.push({ ...seg }));
950
1246
  }
1247
+ await new Promise(r => setTimeout(r, 120));
951
1248
  }
952
1249
  return result;
953
1250
  }
@@ -958,6 +1255,7 @@
958
1255
 
959
1256
  _startDisplay() {
960
1257
  this._stopDisplay();
1258
+ this._applyResponsiveFontSize();
961
1259
  this.updateInterval = setInterval(() => this._tick(), 80);
962
1260
  }
963
1261
 
@@ -973,7 +1271,6 @@
973
1271
  _tick() {
974
1272
  if (!this.subVisible) return;
975
1273
 
976
- // Use translated segments if available, otherwise fall back to originals
977
1274
  const pool = (this.translateLang !== 'off' && this.subtitlesTrans.length > 0)
978
1275
  ? this.subtitlesTrans
979
1276
  : this.subtitles;
@@ -1010,7 +1307,6 @@
1010
1307
 
1011
1308
  _resolveMpdUrl() {
1012
1309
  try {
1013
- // Salvato sul container dal player al momento di initialize()
1014
1310
  const url = this.container.dataset.mpdUrl || this.video.dataset.mpdUrl || null;
1015
1311
  if (url && !url.startsWith('blob:')) return url;
1016
1312
  } catch (_) { }
@@ -1035,7 +1331,6 @@
1035
1331
  if (this.player.options.debug)
1036
1332
  console.log('[AutoSub] _extractAudio — currentSrc:', this.video.currentSrc, '| adaptiveStreamingType:', this.player.adaptiveStreamingType);
1037
1333
 
1038
- // ── DASH: resolve real MPD URL via dash.js instance ───────────────────
1039
1334
  const mpdUrl = this._resolveMpdUrl();
1040
1335
  if (mpdUrl) {
1041
1336
  if (this.player.options.debug) console.log('[AutoSub] DASH mode — mpdUrl:', mpdUrl);
@@ -1046,13 +1341,11 @@
1046
1341
  }
1047
1342
  }
1048
1343
 
1049
- // ── HLS ───────────────────────────────────────────────────────────────
1050
1344
  const src = this.video.currentSrc || this.video.src || '';
1051
1345
  if (src.includes('.m3u8') || this.player.adaptiveStreamingType === 'hls') {
1052
1346
  return await this._extractAudioFromCaptureStream();
1053
1347
  }
1054
1348
 
1055
- // ── Normal MP4 / WebM — direct fetch ─────────────────────────────────
1056
1349
  try { return await this._extractAudioFromFetch(); }
1057
1350
  catch (err) {
1058
1351
  if (this.player.options.debug) console.warn('[AutoSub] Fetch strategy failed:', err.message, '— fallback to captureStream');
@@ -1070,7 +1363,6 @@
1070
1363
  const parser = new DOMParser();
1071
1364
  const mpdDoc = parser.parseFromString(mpdText, 'application/xml');
1072
1365
 
1073
- // Check for XML parse errors
1074
1366
  const parseErr = mpdDoc.querySelector('parsererror');
1075
1367
  if (parseErr) throw new Error('MPD XML parse error: ' + parseErr.textContent.slice(0, 100));
1076
1368
 
@@ -1090,7 +1382,6 @@
1090
1382
 
1091
1383
  this._updatePanel('Downloading audio segments (0/' + segmentUrls.length + ')...', 12);
1092
1384
 
1093
- // Download segments in parallel batches of 5
1094
1385
  const BATCH = 5;
1095
1386
  const segmentBuffers = new Array(segmentUrls.length);
1096
1387
 
@@ -1110,7 +1401,6 @@
1110
1401
  );
1111
1402
  }
1112
1403
 
1113
- // Concatenate all segment buffers into one
1114
1404
  this._updatePanel('Decoding audio...', 30);
1115
1405
  const totalBytes = segmentBuffers.reduce((s, b) => s + b.byteLength, 0);
1116
1406
  const combined = new Uint8Array(totalBytes);
@@ -1146,13 +1436,11 @@
1146
1436
 
1147
1437
  _findAudioAdaptationSet(mpdDoc) {
1148
1438
  const sets = Array.from(mpdDoc.querySelectorAll('AdaptationSet'));
1149
- // Prefer explicit audio contentType or mimeType
1150
1439
  return sets.find(s =>
1151
1440
  s.getAttribute('contentType') === 'audio' ||
1152
1441
  (s.getAttribute('mimeType') || '').startsWith('audio') ||
1153
1442
  s.querySelector('Representation[mimeType^="audio"]') !== null
1154
1443
  ) || sets.find(s =>
1155
- // Fallback: AdaptationSet that is not video
1156
1444
  !(s.getAttribute('mimeType') || '').startsWith('video') &&
1157
1445
  s.getAttribute('contentType') !== 'video'
1158
1446
  );
@@ -1171,7 +1459,6 @@
1171
1459
  _buildSegmentUrlList(mpdDoc, adaptationSet, representation, baseUrl) {
1172
1460
  const urls = [];
1173
1461
 
1174
- // ── SegmentList ──────────────────────────────────────────────────────
1175
1462
  const segList = representation.querySelector('SegmentList')
1176
1463
  || adaptationSet.querySelector('SegmentList');
1177
1464
  if (segList) {
@@ -1187,7 +1474,6 @@
1187
1474
  return urls;
1188
1475
  }
1189
1476
 
1190
- // ── SegmentTemplate ──────────────────────────────────────────────────
1191
1477
  const segTpl = representation.querySelector('SegmentTemplate')
1192
1478
  || adaptationSet.querySelector('SegmentTemplate');
1193
1479
  if (segTpl) {
@@ -1222,7 +1508,6 @@
1222
1508
  return urls;
1223
1509
  }
1224
1510
 
1225
- // ── BaseURL (single audio file) ───────────────────────────────────────
1226
1511
  const baseURLEl = representation.querySelector('BaseURL')
1227
1512
  || adaptationSet.querySelector('BaseURL');
1228
1513
  if (baseURLEl) {
@@ -1240,7 +1525,6 @@
1240
1525
  }
1241
1526
 
1242
1527
  _parseDuration(iso) {
1243
- // Parse ISO 8601 duration: PT1H2M3.5S
1244
1528
  const m = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?/);
1245
1529
  if (!m) return 0;
1246
1530
  return (parseFloat(m[1] || 0) * 3600) +
@@ -1314,7 +1598,6 @@
1314
1598
  if (e.data.size === 0) return;
1315
1599
  chunkQueue.push(e.data);
1316
1600
 
1317
- // Estimate accumulated seconds using rough bitrate of 16 kbps (opus)
1318
1601
  const totalSize = chunkQueue.reduce((s, b) => s + b.size, 0);
1319
1602
  const estimatedSec = totalSize / (16000 / 8);
1320
1603
 
@@ -1328,7 +1611,6 @@
1328
1611
  };
1329
1612
 
1330
1613
  recorder.onstop = async () => {
1331
- // Flush any remaining audio as the last chunk
1332
1614
  if (chunkQueue.length > 0 && this._onAudioChunkReady) {
1333
1615
  const blob = new Blob([...chunkQueue], { type: recorder.mimeType || 'audio/webm' });
1334
1616
  chunkQueue.length = 0;
@@ -1340,7 +1622,6 @@
1340
1622
 
1341
1623
  recorder.onerror = e => reject(e.error || new Error('MediaRecorder error'));
1342
1624
 
1343
- // Update capture progress based on video currentTime
1344
1625
  const duration = isFinite(this.video.duration) && this.video.duration > 0
1345
1626
  ? this.video.duration : null;
1346
1627
 
@@ -1364,10 +1645,8 @@
1364
1645
 
1365
1646
  this.video.addEventListener('ended', stop, { once: true });
1366
1647
 
1367
- // Slice every 2s so ondataavailable fires frequently
1368
1648
  recorder.start(2000);
1369
1649
 
1370
- // Resolve immediately with sentinel — actual transcription flows via callbacks
1371
1650
  resolve({ _captureMode: true, stop });
1372
1651
  });
1373
1652
  }
@@ -1417,17 +1696,14 @@ self.onmessage = async function(e) {
1417
1696
  }
1418
1697
  if (type === 'transcribe') {
1419
1698
  try {
1420
- const audio = new Float32Array(payload.audio);
1421
- const opts = { return_timestamps: true, task: 'transcribe', chunk_length_s: payload.chunkSec };
1422
- if (payload.lang) opts.language = payload.lang;
1423
- const result = await transcriber(audio, opts);
1424
- self.postMessage({ type: 'chunk_done', payload: {
1425
- result, timeOffset: payload.timeOffset,
1426
- chunkIndex: payload.chunkIndex, totalChunks: payload.totalChunks,
1427
- isLast: payload.isLast
1428
- }});
1699
+ const audio = new Float32Array(payload.audio);
1700
+ const opts = { return_timestamps: true, task: 'transcribe', chunk_length_s: payload.chunkSec };
1701
+ if (payload.lang) opts.language = payload.lang;
1702
+ const result = await transcriber(audio, opts);
1703
+ const detectedLang = result.language || payload.lang || null;
1704
+ self.postMessage({ type: 'chunk_done', payload: { result, timeOffset: payload.timeOffset, chunkIndex: payload.chunkIndex, totalChunks: payload.totalChunks, isLast: payload.isLast, detectedLang } });
1429
1705
  } catch(err) {
1430
- self.postMessage({ type: 'error', payload: err.message || String(err) });
1706
+ self.postMessage({ type: 'error', payload: err.message || String(err) });
1431
1707
  }
1432
1708
  }
1433
1709
  };
@@ -1455,9 +1731,7 @@ self.onmessage = async function(e) {
1455
1731
  );
1456
1732
  }
1457
1733
 
1458
- async _transcribeChunksStreaming(audioData) {
1459
-
1460
- // ── CAPTURE STREAM MODE (HLS or DASH fallback) ───────────────────────
1734
+ async _transcribeChunksStreaming(audioData, partialCache = null) {
1461
1735
  if (audioData?._captureMode) {
1462
1736
  const SAMPLE_RATE = 16000;
1463
1737
  const lang = this._resolveLanguage(this.opts.language);
@@ -1465,7 +1739,6 @@ self.onmessage = async function(e) {
1465
1739
 
1466
1740
  if (!this.subVisible) { this.subVisible = true; this._startDisplay(); }
1467
1741
 
1468
- // Worker starts in parallel while audio is still being captured
1469
1742
  const worker = this._createTranscriberWorker();
1470
1743
  this._worker = worker;
1471
1744
 
@@ -1547,13 +1820,21 @@ self.onmessage = async function(e) {
1547
1820
 
1548
1821
  if (type === 'chunk_done') {
1549
1822
  const { result, timeOffset, isLast } = payload;
1550
- const rawChunks = (result.chunks || []).map(c => {
1551
- const ts0 = c.timestamp?.[0] ?? 0;
1552
- const ts1 = c.timestamp?.[1] ?? ts0 + 3;
1553
- return { text: (c.text || '').trim(), start: ts0 + timeOffset, end: ts1 + timeOffset };
1554
- });
1823
+
1824
+ let rawChunks = (result.chunks || [])
1825
+ .filter(c => c.text?.trim())
1826
+ .map(c => {
1827
+ const ts0 = (c.timestamp?.[0] != null) ? c.timestamp[0] : 0;
1828
+ const ts1 = (c.timestamp?.[1] != null) ? c.timestamp[1] : ts0 + 5;
1829
+ return { text: c.text.trim(), start: ts0 + timeOffset, end: ts1 + timeOffset };
1830
+ });
1831
+
1832
+ if (rawChunks.length === 0 && result.text?.trim()) {
1833
+ rawChunks = [{ text: result.text.trim(), start: timeOffset, end: timeOffset + 30 }];
1834
+ }
1835
+
1555
1836
  this.subtitles.push(...this._normalizeChunks(rawChunks));
1556
- if (this.opts.cacheEnabled) this._saveToCache(this.subtitles);
1837
+ if (this.opts.cacheEnabled) this._saveToCache(this.subtitles, null, null);
1557
1838
 
1558
1839
  activeJob = false;
1559
1840
 
@@ -1586,15 +1867,22 @@ self.onmessage = async function(e) {
1586
1867
  return promise;
1587
1868
  }
1588
1869
 
1589
- // ── NORMAL MODE (Float32Array from fetch or DASH segment download) ────
1590
1870
  const float32Audio = audioData;
1591
1871
  const SAMPLE_RATE = 16000;
1592
1872
  const CHUNK_SEC = 30;
1593
1873
  const chunkSize = SAMPLE_RATE * CHUNK_SEC;
1594
1874
  const totalChunks = Math.ceil(float32Audio.length / chunkSize);
1595
1875
  const lang = this._resolveLanguage(this.opts.language);
1876
+ let detectedLang = lang || partialCache?.detectedLang || null;
1596
1877
  const modelId = WHISPER_MODELS[this.opts.modelSize] || WHISPER_MODELS.base;
1597
1878
 
1879
+ const startChunk = (partialCache?.chunksCompleted != null && partialCache.chunksCompleted < totalChunks)
1880
+ ? partialCache.chunksCompleted
1881
+ : 0;
1882
+
1883
+ if (this.player.options.debug && startChunk > 0)
1884
+ console.log('[AutoSub] Resuming transcription from chunk', startChunk + 1, 'of', totalChunks);
1885
+
1598
1886
  if (!this.subVisible) { this.subVisible = true; this._startDisplay(); }
1599
1887
 
1600
1888
  const worker = this._createTranscriberWorker();
@@ -1619,27 +1907,68 @@ self.onmessage = async function(e) {
1619
1907
  if (type === 'ready') {
1620
1908
  URL.revokeObjectURL(worker._bundleURL);
1621
1909
  URL.revokeObjectURL(worker._workerURL);
1622
- this._updatePanel('Transcribing chunk 1 of ' + totalChunks + '...', 64);
1623
- this._sendChunk(worker, float32Audio, 0, chunkSize, totalChunks, lang, CHUNK_SEC);
1910
+ if (this.player.options.debug) console.log('[AutoSub] Worker ready — sending chunk', startChunk + 1, 'of', totalChunks);
1911
+ this._updatePanel('Transcribing chunk ' + (startChunk + 1) + ' of ' + totalChunks + '...', 64);
1912
+ this._sendChunk(worker, float32Audio, startChunk, chunkSize, totalChunks, detectedLang, CHUNK_SEC);
1624
1913
  }
1625
1914
 
1626
1915
  if (type === 'chunk_done') {
1627
1916
  const { result, timeOffset, chunkIndex } = payload;
1628
- const rawChunks = (result.chunks || []).map(c => {
1629
- const ts0 = c.timestamp?.[0] ?? 0;
1630
- const ts1 = c.timestamp?.[1] ?? ts0 + 3;
1631
- return { text: (c.text || '').trim(), start: ts0 + timeOffset, end: ts1 + timeOffset };
1632
- });
1633
- this.subtitles.push(...this._normalizeChunks(rawChunks));
1634
- if (this.opts.cacheEnabled) this._saveToCache(this.subtitles);
1917
+
1918
+ if (chunkIndex === startChunk && payload.detectedLang && !detectedLang) {
1919
+ detectedLang = payload.detectedLang;
1920
+ if (this.player.options.debug) console.log('[AutoSub] Language auto-detected:', detectedLang);
1921
+ }
1922
+ this._saveToCache(this.subtitles, chunkIndex + 1, totalChunks, detectedLang);
1923
+
1924
+ let rawChunks = (result.chunks || [])
1925
+ .filter(c => c.text?.trim())
1926
+ .map(c => {
1927
+ const ts0 = (c.timestamp?.[0] != null) ? c.timestamp[0] : 0;
1928
+ const ts1 = (c.timestamp?.[1] != null) ? c.timestamp[1] : ts0 + 5;
1929
+ return { text: c.text.trim(), start: ts0 + timeOffset, end: ts1 + timeOffset };
1930
+ });
1931
+
1932
+ if (rawChunks.length === 0 && result.text?.trim()) {
1933
+ rawChunks = [{ text: result.text.trim(), start: timeOffset, end: timeOffset + CHUNK_SEC }];
1934
+ }
1935
+
1936
+ const normalized = this._normalizeChunks(rawChunks);
1937
+ this.subtitles.push(...normalized);
1938
+
1939
+ this._saveToCache(this.subtitles, chunkIndex + 1, totalChunks, detectedLang);
1940
+
1941
+ if (this.translateLang && this.translateLang !== 'off' && normalized.length > 0) {
1942
+ const currentLang = this.translateLang;
1943
+ this._translateBatch(normalized, currentLang).then(translated => {
1944
+ if (this.translateLang === currentLang) {
1945
+ this.subtitlesTrans.push(...translated);
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);
1952
+ }
1953
+ }).catch(() => {
1954
+ if (this.translateLang === currentLang) {
1955
+ this.subtitlesTrans.push(...normalized);
1956
+ this.subtitlesTrans.sort((a, b) => a.start - b.start);
1957
+ }
1958
+ });
1959
+ }
1960
+
1635
1961
 
1636
1962
  const next = chunkIndex + 1;
1637
1963
  if (next < totalChunks) {
1638
1964
  const pct = 64 + Math.round((next / totalChunks) * 33);
1639
1965
  this._updatePanel('Transcribing chunk ' + (next + 1) + ' of ' + totalChunks + '...', pct);
1640
- this._sendChunk(worker, float32Audio, next, chunkSize, totalChunks, lang, CHUNK_SEC);
1966
+ this._sendChunk(worker, float32Audio, next, chunkSize, totalChunks, detectedLang, CHUNK_SEC);
1641
1967
  } else {
1642
- worker.terminate(); this._worker = null; resolve();
1968
+ if (this.player.options.debug) console.log('[AutoSub] All chunks done, subtitles:', this.subtitles.length);
1969
+ worker.terminate(); this._worker = null;
1970
+ resolve();
1971
+ this._lastDetectedLang = detectedLang;
1643
1972
  }
1644
1973
  }
1645
1974
 
@@ -1652,6 +1981,7 @@ self.onmessage = async function(e) {
1652
1981
  };
1653
1982
 
1654
1983
  worker.postMessage({ type: 'init', payload: { modelId } });
1984
+ if (this.player.options.debug) console.log('[AutoSub] Init sent (normal), modelId:', modelId, 'totalChunks:', totalChunks, 'startChunk:', startChunk);
1655
1985
  });
1656
1986
  }
1657
1987
 
@@ -1696,7 +2026,6 @@ self.onmessage = async function(e) {
1696
2026
  const safe = String(this.opts.idCache).replace(/[^a-zA-Z0-9_-]/g, '_');
1697
2027
  return CACHE_PREFIX + safe;
1698
2028
  }
1699
- // Use the MPD URL or player.options.src as stable key — currentSrc may be a blob URL
1700
2029
  const src = this._resolveMpdUrl()
1701
2030
  || this.player.options?.src
1702
2031
  || this.video.currentSrc
@@ -1720,18 +2049,46 @@ self.onmessage = async function(e) {
1720
2049
  if (!raw) return null;
1721
2050
  const parsed = JSON.parse(raw);
1722
2051
  if (!Array.isArray(parsed.subtitles) || !parsed.subtitles.length) return null;
1723
- return parsed.subtitles;
1724
- } catch (_) { return null; }
2052
+ return {
2053
+ subtitles: parsed.subtitles,
2054
+ chunksCompleted: parsed.chunksCompleted ?? null,
2055
+ totalChunks: parsed.totalChunks ?? null,
2056
+ detectedLang: parsed.detectedLang ?? null,
2057
+ transCache: parsed.transCache || {},
2058
+ isImportedSubs: parsed.isImportedSubs || false
2059
+ };
2060
+ } catch {
2061
+ return null;
2062
+ }
1725
2063
  }
1726
2064
 
1727
- _saveToCache(subtitles) {
2065
+ _saveToCache(subtitles, chunksCompleted = null, totalChunks = null, detectedLang = null) {
1728
2066
  if (!this.opts.cacheEnabled || !subtitles.length) return;
1729
2067
  try {
1730
2068
  const key = this._getCacheKey();
1731
- const data = JSON.stringify({ subtitles, savedAt: Date.now() });
1732
- try { localStorage.setItem(key, data); }
1733
- catch (_) { this._evictOldestCache(); try { localStorage.setItem(key, data); } catch (_) { } }
1734
- } catch (_) { }
2069
+ const data = JSON.stringify({
2070
+ subtitles,
2071
+ chunksCompleted,
2072
+ totalChunks,
2073
+ detectedLang,
2074
+ transCache: this._transCache || {},
2075
+ isImportedSubs: this.isImportedSubs || false,
2076
+ savedAt: Date.now()
2077
+ });
2078
+ try {
2079
+ localStorage.setItem(key, data);
2080
+ } catch {
2081
+ this._evictOldestCache();
2082
+ try { localStorage.setItem(key, data); } catch { /* storage full */ }
2083
+ }
2084
+ } catch { /* ignore */ }
2085
+ }
2086
+
2087
+ _deleteCache() {
2088
+ if (!this.opts.cacheEnabled) return;
2089
+ try {
2090
+ localStorage.removeItem(this._getCacheKey());
2091
+ } catch { }
1735
2092
  }
1736
2093
 
1737
2094
  _evictOldestCache() {
@@ -1771,4 +2128,4 @@ self.onmessage = async function(e) {
1771
2128
  };
1772
2129
 
1773
2130
  register();
1774
- })();
2131
+ })();