@uimaxbai/am-lyrics 1.5.3 → 1.5.5

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.
@@ -319,7 +319,7 @@ class GoogleService {
319
319
  }
320
320
  }
321
321
 
322
- const VERSION = '1.5.3';
322
+ const VERSION = '1.5.5';
323
323
  const INSTRUMENTAL_THRESHOLD_MS = 7000; // Show dots for gaps >= 7s
324
324
  const FETCH_TIMEOUT_MS = 8000; // Timeout for all lyrics fetch requests
325
325
  const SEEK_THRESHOLD_MS = 500;
@@ -328,6 +328,15 @@ const GAP_PULSE_DURATION_MS = 4000;
328
328
  const GAP_PULSE_CYCLE_MS = GAP_PULSE_DURATION_MS * 2;
329
329
  const GAP_EXIT_LEAD_MS = 600;
330
330
  const GAP_MIN_SCALE = 0.85;
331
+ const NEXT_WORD_PRE_WIPE_MAX_GAP_MS = 180;
332
+ const NEXT_WORD_PRE_WIPE_MIN_DURATION_MS = 80;
333
+ const NEXT_WORD_PRE_WIPE_MAX_DURATION_MS = 240;
334
+ const BASE_WIPE_GRADIENT_EM = 0.75;
335
+ const LONG_WORD_WIPE_EXTRA_EM = 0.45;
336
+ const LONG_WORD_WIPE_EXTRA_RATIO = 0.35;
337
+ const SHORT_WORD_DRAG_MIN_DURATION_MS = 760;
338
+ const SHORT_WORD_GLOW_MIN_DURATION_MS = 1320;
339
+ const WORD_PRE_WIPE_HANDOFF_LEAD_MS = 120;
331
340
  /**
332
341
  * Fetch with an automatic timeout via AbortSignal.
333
342
  * Rejects if the request takes longer than `timeoutMs`.
@@ -389,6 +398,7 @@ class AmLyrics extends i {
389
398
  this.activeLineIds = new Set();
390
399
  this.currentPrimaryActiveLine = null;
391
400
  this.lastPrimaryActiveLine = null;
401
+ this.backgroundExpandedLine = null;
392
402
  // Scroll animation state
393
403
  this.scrollAnimationState = null;
394
404
  this.currentScrollOffset = 0;
@@ -486,6 +496,7 @@ class AmLyrics extends i {
486
496
  this.preActiveLineElements = [];
487
497
  this.positionedLineElements = [];
488
498
  this.activeGapLineElements = [];
499
+ this.clearBackgroundExpandedLine();
489
500
  // Stop all running animations and clear highlights immediately
490
501
  if (this.lyricsContainer) {
491
502
  const activeLines = this.lyricsContainer.querySelectorAll('.lyrics-line.active, .lyrics-line.pre-active, .lyrics-line.bg-expanded');
@@ -518,7 +529,7 @@ class AmLyrics extends i {
518
529
  switchBtn.disabled = this.isFetchingAlternatives;
519
530
  }
520
531
  if (svgEl) {
521
- svgEl.setAttribute('style', `margin-right: 4px; ${this.isFetchingAlternatives ? 'animation: spin 1s linear infinite;' : ''}`);
532
+ svgEl.classList.toggle('is-loading', this.isFetchingAlternatives);
522
533
  }
523
534
  if (labelEl) {
524
535
  labelEl.textContent = this.isFetchingAlternatives
@@ -691,6 +702,7 @@ class AmLyrics extends i {
691
702
  this.preActiveLineElements = [];
692
703
  this.positionedLineElements = [];
693
704
  this.activeGapLineElements = [];
705
+ this.clearBackgroundExpandedLine();
694
706
  if (this.lyricsContainer) {
695
707
  this.isProgrammaticScroll = true;
696
708
  this.lyricsContainer.scrollTop = 0;
@@ -758,14 +770,23 @@ class AmLyrics extends i {
758
770
  return 21;
759
771
  return 30;
760
772
  }
773
+ static getDisplaySourceLabel(sourceLabel) {
774
+ return sourceLabel.toLowerCase().includes('lyricsplus')
775
+ ? 'QQ'
776
+ : sourceLabel;
777
+ }
778
+ static getSourceKey(sourceLabel) {
779
+ const lower = (sourceLabel || '').trim().toLowerCase();
780
+ if (!lower)
781
+ return '';
782
+ if (lower.includes('lyricsplus') || lower === 'qq')
783
+ return 'qq';
784
+ return lower.replace(/\s+/g, ' ');
785
+ }
761
786
  static mergeAndSortSources(collectedSources) {
762
787
  const uniqueSourcesMap = new Map();
763
788
  for (const source of collectedSources) {
764
- const normalizedSource = source.source
765
- .toLowerCase()
766
- .includes('lyricsplus')
767
- ? 'QQ'
768
- : source.source;
789
+ const normalizedSource = AmLyrics.getDisplaySourceLabel(source.source);
769
790
  if (!uniqueSourcesMap.has(normalizedSource)) {
770
791
  uniqueSourcesMap.set(normalizedSource, {
771
792
  ...source,
@@ -776,9 +797,43 @@ class AmLyrics extends i {
776
797
  return Array.from(uniqueSourcesMap.values()).sort((a, b) => AmLyrics.getRankForCollected(a.source, a.lines) -
777
798
  AmLyrics.getRankForCollected(b.source, b.lines));
778
799
  }
800
+ findCurrentSourceIndex(sources = this.availableSources, sourceLabel = this.lyricsSource, lines = this.lyrics) {
801
+ const identityIndex = sources.findIndex(source => source.lines === lines);
802
+ if (identityIndex !== -1)
803
+ return identityIndex;
804
+ const sourceKey = AmLyrics.getSourceKey(sourceLabel);
805
+ if (!sourceKey)
806
+ return -1;
807
+ return sources.findIndex(source => AmLyrics.getSourceKey(source.source) === sourceKey);
808
+ }
809
+ static getNextSourceIndex(sources, currentIndex, currentSourceLabel, currentLines) {
810
+ if (sources.length <= 1)
811
+ return -1;
812
+ if (currentIndex !== -1) {
813
+ return (currentIndex + 1) % sources.length;
814
+ }
815
+ const currentKey = AmLyrics.getSourceKey(currentSourceLabel);
816
+ const fallbackIndex = sources.findIndex(source => source.lines !== currentLines &&
817
+ AmLyrics.getSourceKey(source.source) !== currentKey);
818
+ return fallbackIndex === -1 ? 0 : fallbackIndex;
819
+ }
820
+ async applySourceAtIndex(index) {
821
+ const sourceResult = this.availableSources[index];
822
+ if (!sourceResult)
823
+ return;
824
+ this.currentSourceIndex = index;
825
+ this.lyrics = sourceResult.lines;
826
+ this.lyricsSource = sourceResult.source;
827
+ if (sourceResult.songwriters) {
828
+ this.songwriters = sourceResult.songwriters;
829
+ }
830
+ await this.onLyricsLoaded();
831
+ }
779
832
  async switchSource() {
780
833
  if (this.isFetchingAlternatives)
781
834
  return;
835
+ const currentSourceLabel = this.lyricsSource;
836
+ const currentLines = this.lyrics;
782
837
  if (!this.hasFetchedAllProviders) {
783
838
  this.isFetchingAlternatives = true;
784
839
  this._updateFooter();
@@ -821,10 +876,9 @@ class AmLyrics extends i {
821
876
  ...this.availableSources,
822
877
  ...newSources,
823
878
  ]);
824
- // Re-sync current index since sorting might shift elements
825
- this.currentSourceIndex = this.availableSources.findIndex(s => s.source === this.lyricsSource);
826
- if (this.currentSourceIndex === -1)
827
- this.currentSourceIndex = 0;
879
+ // Re-sync current index since sorting or label normalization can
880
+ // shift the currently displayed source underneath the old index.
881
+ this.currentSourceIndex = this.findCurrentSourceIndex(this.availableSources, currentSourceLabel, currentLines);
828
882
  }
829
883
  }
830
884
  }
@@ -835,15 +889,11 @@ class AmLyrics extends i {
835
889
  }
836
890
  }
837
891
  if (this.availableSources.length > 1) {
838
- this.currentSourceIndex =
839
- (this.currentSourceIndex + 1) % this.availableSources.length;
840
- const sourceResult = this.availableSources[this.currentSourceIndex];
841
- this.lyrics = sourceResult.lines;
842
- this.lyricsSource = sourceResult.source;
843
- if (sourceResult.songwriters) {
844
- this.songwriters = sourceResult.songwriters;
892
+ const currentIndex = this.findCurrentSourceIndex(this.availableSources, currentSourceLabel, currentLines);
893
+ const nextIndex = AmLyrics.getNextSourceIndex(this.availableSources, currentIndex, currentSourceLabel, currentLines);
894
+ if (nextIndex !== -1) {
895
+ await this.applySourceAtIndex(nextIndex);
845
896
  }
846
- await this.onLyricsLoaded();
847
897
  }
848
898
  }
849
899
  async resolveSongMetadata() {
@@ -1895,7 +1945,9 @@ class AmLyrics extends i {
1895
1945
  if (!newActiveLines.includes(lineIndex)) {
1896
1946
  const lineElement = this._getLineElement(lineIndex);
1897
1947
  if (lineElement) {
1898
- if (isSeek || this.isUserScrolling) {
1948
+ if (isSeek ||
1949
+ this.isUserScrolling ||
1950
+ AmLyrics.isLineSyncedLine(this.lyrics?.[lineIndex])) {
1899
1951
  AmLyrics.unfinishSyllables(lineElement);
1900
1952
  }
1901
1953
  else {
@@ -1911,12 +1963,13 @@ class AmLyrics extends i {
1911
1963
  }
1912
1964
  }
1913
1965
  }
1914
- // Add 'active' and 'bg-expanded' to newly active lines
1966
+ // Add 'active' to newly active lines. Background expansion is driven
1967
+ // separately by the current scroll target.
1915
1968
  for (const lineIndex of newActiveLines) {
1916
1969
  if (!oldActiveLines.includes(lineIndex)) {
1917
1970
  const lineElement = this._getLineElement(lineIndex);
1918
1971
  if (lineElement) {
1919
- lineElement.classList.add('active', 'bg-expanded');
1972
+ lineElement.classList.add('active');
1920
1973
  lineElement.classList.remove('pre-active');
1921
1974
  const preIdx = this.preActiveLineElements.indexOf(lineElement);
1922
1975
  if (preIdx !== -1)
@@ -2136,8 +2189,12 @@ class AmLyrics extends i {
2136
2189
  for (const lineIndex of activeLines) {
2137
2190
  const lineEl = this._getLineElement(lineIndex);
2138
2191
  if (lineEl)
2139
- lineEl.classList.add('active', 'bg-expanded');
2192
+ lineEl.classList.add('active');
2140
2193
  }
2194
+ const primaryActiveLine = this.getPrimaryActiveLineIndex(activeLines);
2195
+ this.setBackgroundExpandedLine(primaryActiveLine !== null
2196
+ ? this._getLineElement(primaryActiveLine)
2197
+ : null);
2141
2198
  // Trigger a faux time-change so that updateSyllablesForLine fires
2142
2199
  // to setup inline syllable CSS wipe animations for whatever the current time is
2143
2200
  this._onTimeChanged(0, this.currentTime);
@@ -2176,6 +2233,7 @@ class AmLyrics extends i {
2176
2233
  this.preActiveLineElements = [];
2177
2234
  this.positionedLineElements = [];
2178
2235
  this.activeGapLineElements = [];
2236
+ this.clearBackgroundExpandedLine();
2179
2237
  this.setUserScrolling(false);
2180
2238
  // Cancel any running animations
2181
2239
  if (this.animationFrameId) {
@@ -2230,6 +2288,7 @@ class AmLyrics extends i {
2230
2288
  // Don't override it with a scroll back to the last lyric.
2231
2289
  const footer = this.lyricsContainer.querySelector('.lyrics-footer');
2232
2290
  if (footer?.classList.contains('active')) {
2291
+ this.setBackgroundExpandedLine(null);
2233
2292
  return;
2234
2293
  }
2235
2294
  // 1. Compute scroll lookahead based on gap to next line (YouLyPlus style)
@@ -2270,17 +2329,21 @@ class AmLyrics extends i {
2270
2329
  targetElement = this._getLineElement(targetLineIdx);
2271
2330
  }
2272
2331
  }
2273
- if (!targetElement)
2332
+ if (!targetElement) {
2333
+ this.setBackgroundExpandedLine(null);
2274
2334
  return;
2275
- // Unblur the upcoming target line early (pre-active) so background
2276
- // vocals start their max-height/opacity transition in sync with scroll.
2335
+ }
2336
+ const scrollDuration = scrollLookAheadMs;
2337
+ targetElement.style.setProperty('--scroll-duration', `${scrollDuration}ms`);
2338
+ this.setBackgroundExpandedLine(targetElement);
2339
+ // Unblur the upcoming target line early while the separate bg-expanded
2340
+ // class starts background vocal height/opacity in sync with scroll.
2277
2341
  if (!targetElement.classList.contains('active')) {
2278
2342
  targetElement.classList.add('pre-active');
2279
2343
  if (!this.preActiveLineElements.includes(targetElement)) {
2280
2344
  this.preActiveLineElements.push(targetElement);
2281
2345
  }
2282
2346
  }
2283
- const scrollDuration = scrollLookAheadMs;
2284
2347
  this.focusLine(targetElement, forceScroll, scrollDuration);
2285
2348
  }
2286
2349
  _getTextWidth(text, font) {
@@ -2353,6 +2416,7 @@ class AmLyrics extends i {
2353
2416
  this.preActiveLineElements = [];
2354
2417
  this.positionedLineElements = [];
2355
2418
  this.activeGapLineElements = [];
2419
+ this.clearBackgroundExpandedLine();
2356
2420
  this.visibilityObserver?.disconnect();
2357
2421
  this.visibilityObserver = undefined;
2358
2422
  }
@@ -2388,6 +2452,7 @@ class AmLyrics extends i {
2388
2452
  const groupGrowable = new Array(wordGroups.length).fill(false);
2389
2453
  const groupGlowing = new Array(wordGroups.length).fill(false);
2390
2454
  const groupCharRise = new Array(wordGroups.length).fill(false);
2455
+ const groupCharDrag = new Array(wordGroups.length).fill(false);
2391
2456
  const vwFullText = new Array(wordGroups.length).fill('');
2392
2457
  const vwFullDuration = new Array(wordGroups.length).fill(0);
2393
2458
  const vwCharOffset = new Array(wordGroups.length).fill(0);
@@ -2419,25 +2484,46 @@ class AmLyrics extends i {
2419
2484
  lineIsRTL = true;
2420
2485
  const hasHyphen = combinedText.includes('-');
2421
2486
  const wordLen = combinedText.length;
2422
- let isGrowableVW = !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 7;
2487
+ const canAnimateByChar = !isCJK && !isRTL && !hasHyphen && wordLen > 0;
2488
+ const isLineSynced = line.isWordSynced === false || line.text.some(s => s.lineSynced);
2489
+ let isGrowableVW = canAnimateByChar && wordLen > 0 && wordLen <= 7;
2423
2490
  if (isGrowableVW) {
2424
- if (wordLen < 3) {
2491
+ if (wordLen <= 1) {
2425
2492
  isGrowableVW =
2426
2493
  combinedDuration >= 1050 && combinedDuration >= wordLen * 525;
2427
2494
  }
2495
+ else if (wordLen <= 3) {
2496
+ isGrowableVW =
2497
+ combinedDuration >=
2498
+ SHORT_WORD_GLOW_MIN_DURATION_MS + (wordLen - 2) * 140;
2499
+ }
2428
2500
  else {
2429
2501
  isGrowableVW =
2430
2502
  combinedDuration >= 850 && combinedDuration >= wordLen * 190;
2431
2503
  }
2432
2504
  }
2433
- const isLineSynced = line.isWordSynced === false || line.text.some(s => s.lineSynced);
2505
+ const hasCharRiseDuration = combinedDuration >= Math.max(700, wordLen * 85);
2506
+ const hasTinyWordDragDuration = wordLen >= 2 &&
2507
+ wordLen <= 3 &&
2508
+ combinedDuration >=
2509
+ Math.max(SHORT_WORD_DRAG_MIN_DURATION_MS, wordLen * 150);
2510
+ const hasLongShortWordDuration = wordLen >= 4 && combinedDuration >= Math.max(1300, wordLen * 260);
2511
+ const isCharRiseVW = canAnimateByChar &&
2512
+ !isLineSynced &&
2513
+ !isGrowableVW &&
2514
+ ((wordLen >= 8 && hasCharRiseDuration) ||
2515
+ (wordLen < 8 && hasLongShortWordDuration));
2516
+ const isCharDragVW = canAnimateByChar &&
2517
+ !isLineSynced &&
2518
+ !isGrowableVW &&
2519
+ hasTinyWordDragDuration;
2434
2520
  const isGlowingVW = isGrowableVW && !isLineSynced;
2435
- const isCharRiseVW = !isGrowableVW && !isLineSynced && !isCJK && !isRTL && wordLen >= 8;
2436
2521
  let charOff = 0;
2437
2522
  for (let gi = vwStart; gi <= vwEnd; gi += 1) {
2438
2523
  groupGrowable[gi] = isGrowableVW;
2439
2524
  groupGlowing[gi] = isGlowingVW;
2440
2525
  groupCharRise[gi] = isCharRiseVW;
2526
+ groupCharDrag[gi] = isCharDragVW;
2441
2527
  vwFullText[gi] = combinedText;
2442
2528
  vwFullDuration[gi] = combinedDuration;
2443
2529
  vwCharOffset[gi] = charOff;
@@ -2453,6 +2539,7 @@ class AmLyrics extends i {
2453
2539
  groupGrowable,
2454
2540
  groupGlowing,
2455
2541
  groupCharRise,
2542
+ groupCharDrag,
2456
2543
  vwFullText,
2457
2544
  vwFullDuration,
2458
2545
  vwCharOffset,
@@ -2472,48 +2559,97 @@ class AmLyrics extends i {
2472
2559
  return;
2473
2560
  const computedStyle = getComputedStyle(referenceSyllable);
2474
2561
  const { font } = computedStyle; // Full font string
2475
- const fontSize = parseFloat(computedStyle.fontSize);
2476
- const charTimedWords = this.shadowRoot.querySelectorAll('.lyrics-word.growable, .lyrics-word.char-rise');
2477
- if (!charTimedWords)
2562
+ const charTimedWords = Array.from(this.shadowRoot.querySelectorAll('.lyrics-word.growable, .lyrics-word.char-rise, .lyrics-word.char-drag'));
2563
+ if (charTimedWords.length === 0)
2478
2564
  return;
2479
- charTimedWords.forEach((wordSpan) => {
2480
- const syllableWraps = wordSpan.querySelectorAll('.lyrics-syllable-wrap');
2481
- // Flatten syllables
2565
+ const wordsByVirtualId = new Map();
2566
+ charTimedWords.forEach((wordSpan, index) => {
2567
+ const virtualWordId = wordSpan.dataset.virtualWordId || `word-${index}`;
2568
+ const words = wordsByVirtualId.get(virtualWordId);
2569
+ if (words) {
2570
+ words.push(wordSpan);
2571
+ }
2572
+ else {
2573
+ wordsByVirtualId.set(virtualWordId, [wordSpan]);
2574
+ }
2575
+ });
2576
+ wordsByVirtualId.forEach(wordSpans => {
2482
2577
  const syllables = [];
2483
- syllableWraps.forEach((wrap) => {
2484
- const syl = wrap.querySelector('.lyrics-syllable');
2485
- if (syl)
2486
- syllables.push(syl);
2578
+ wordSpans.forEach(wordSpan => {
2579
+ const syllableWraps = wordSpan.querySelectorAll('.lyrics-syllable-wrap');
2580
+ syllableWraps.forEach(wrap => {
2581
+ const syl = wrap.querySelector('.lyrics-syllable');
2582
+ if (syl)
2583
+ syllables.push(syl);
2584
+ });
2487
2585
  });
2488
- syllables.forEach(sylSpan => {
2489
- const charSpans = sylSpan.querySelectorAll('.char');
2490
- if (charSpans.length === 0)
2491
- return;
2492
- // Logic from YouLyPlus renderCharWipes:
2493
- // Use textContent from spans to ensure we measure what is rendered
2494
- const chars = Array.from(charSpans).map(span => span.textContent || '');
2495
- const charWidths = chars.map(c => this._getTextWidth(c, font));
2496
- const totalSyllableWidth = charWidths.reduce((a, b) => a + b, 0);
2497
- const duration = parseFloat(sylSpan.dataset.duration || '0');
2498
- const velocityPxPerMs = duration > 0 ? totalSyllableWidth / duration : 0;
2499
- // Gradient width in pixels = 0.375 * fontSize
2500
- // This matches YouLyPlus visual gradient size
2501
- const gradientWidthPx = 0.375 * fontSize;
2502
- const gradientDurationMs = velocityPxPerMs > 0 ? gradientWidthPx / velocityPxPerMs : 100;
2503
- let cumulativeCharWidth = 0;
2504
- charSpans.forEach((spanArg, i) => {
2505
- const charWidth = charWidths[i];
2506
- const span = spanArg;
2507
- if (totalSyllableWidth > 0) {
2508
- const startPercent = cumulativeCharWidth / totalSyllableWidth;
2509
- const durationPercent = charWidth / totalSyllableWidth;
2510
- span.dataset.wipeStart = startPercent.toFixed(4);
2511
- span.dataset.wipeDuration = durationPercent.toFixed(4);
2512
- // The critical missing piece:
2513
- span.dataset.preWipeArrival = (duration * startPercent).toFixed(2);
2514
- span.dataset.preWipeDuration = gradientDurationMs.toFixed(2);
2586
+ const charSpans = syllables.flatMap(syl => {
2587
+ const spans = Array.from(syl.querySelectorAll('.char'));
2588
+ const target = syl;
2589
+ target._cachedCharSpans = spans;
2590
+ return spans;
2591
+ });
2592
+ if (charSpans.length === 0)
2593
+ return;
2594
+ wordSpans.forEach(wordSpan => {
2595
+ const target = wordSpan;
2596
+ target._cachedVirtualWordElements = wordSpans;
2597
+ target._cachedVirtualWordCharSpans = charSpans;
2598
+ });
2599
+ const syllableEntries = syllables.map(syl => {
2600
+ const spans = syl._cachedCharSpans;
2601
+ const charWidths = spans.map(span => this._getTextWidth(span.textContent || '', font));
2602
+ const totalWidth = charWidths.reduce((a, b) => a + b, 0);
2603
+ return {
2604
+ syl,
2605
+ spans,
2606
+ charWidths,
2607
+ totalWidth,
2608
+ start: parseFloat(syl.getAttribute('data-start-time') || ''),
2609
+ end: parseFloat(syl.getAttribute('data-end-time') || ''),
2610
+ };
2611
+ });
2612
+ const totalWordWidth = syllableEntries.reduce((total, entry) => total + entry.totalWidth, 0);
2613
+ if (totalWordWidth <= 0)
2614
+ return;
2615
+ const virtualWordStart = Math.min(...syllableEntries
2616
+ .map(entry => entry.start)
2617
+ .filter(start => Number.isFinite(start)));
2618
+ const virtualWordEnd = Math.max(...syllableEntries
2619
+ .map(entry => entry.end)
2620
+ .filter(end => Number.isFinite(end)));
2621
+ const virtualWordDuration = virtualWordEnd - virtualWordStart;
2622
+ const hasTimedSyllables = Number.isFinite(virtualWordStart) &&
2623
+ Number.isFinite(virtualWordEnd) &&
2624
+ virtualWordDuration > 0;
2625
+ let cumulativeCharWidth = 0;
2626
+ syllableEntries.forEach(entry => {
2627
+ let cumulativeSyllableWidth = 0;
2628
+ const syllableDuration = entry.end - entry.start;
2629
+ const useSyllableTiming = hasTimedSyllables &&
2630
+ Number.isFinite(entry.start) &&
2631
+ Number.isFinite(entry.end) &&
2632
+ syllableDuration > 0 &&
2633
+ entry.totalWidth > 0;
2634
+ entry.spans.forEach((span, index) => {
2635
+ const charWidth = entry.charWidths[index];
2636
+ let startPercent = cumulativeCharWidth / totalWordWidth;
2637
+ let durationPercent = charWidth / totalWordWidth;
2638
+ if (useSyllableTiming) {
2639
+ const charStartMs = entry.start -
2640
+ virtualWordStart +
2641
+ (cumulativeSyllableWidth / entry.totalWidth) * syllableDuration;
2642
+ const charDurationMs = (charWidth / entry.totalWidth) * syllableDuration;
2643
+ startPercent = AmLyrics.clamp(charStartMs / virtualWordDuration, 0, 1);
2644
+ durationPercent = AmLyrics.clamp(charDurationMs / virtualWordDuration, 0, 1);
2515
2645
  }
2646
+ const target = span;
2647
+ target.dataset.wipeStart = startPercent.toFixed(4);
2648
+ target.dataset.wipeDuration = durationPercent.toFixed(4);
2649
+ target.style.setProperty('--word-wipe-width', `${totalWordWidth}px`);
2650
+ target.style.setProperty('--char-wipe-position', `${-cumulativeCharWidth}px`);
2516
2651
  cumulativeCharWidth += charWidth;
2652
+ cumulativeSyllableWidth += charWidth;
2517
2653
  });
2518
2654
  });
2519
2655
  });
@@ -2521,6 +2657,33 @@ class AmLyrics extends i {
2521
2657
  static arraysEqual(a, b) {
2522
2658
  return a.length === b.length && a.every((val, i) => val === b[i]);
2523
2659
  }
2660
+ static isLineSyncedLine(line) {
2661
+ if (!line)
2662
+ return false;
2663
+ return line.isWordSynced === false || line.text.some(s => s.lineSynced);
2664
+ }
2665
+ getLineHighlightEndTime(index) {
2666
+ if (!this.lyrics)
2667
+ return 0;
2668
+ const line = this.lyrics[index];
2669
+ if (!line)
2670
+ return 0;
2671
+ const rawEnd = Math.max(line.endtime, line.timestamp);
2672
+ const nextLine = this.lyrics[index + 1];
2673
+ if (!nextLine || nextLine.timestamp <= line.timestamp) {
2674
+ return rawEnd > line.timestamp ? rawEnd + 200 : rawEnd;
2675
+ }
2676
+ if (rawEnd > line.timestamp) {
2677
+ if (nextLine.timestamp < rawEnd) {
2678
+ return rawEnd;
2679
+ }
2680
+ const gapToNext = nextLine.timestamp - rawEnd;
2681
+ if (gapToNext >= INSTRUMENTAL_THRESHOLD_MS) {
2682
+ return rawEnd;
2683
+ }
2684
+ }
2685
+ return nextLine.timestamp;
2686
+ }
2524
2687
  static getLineIndexFromElement(lineElement) {
2525
2688
  if (!lineElement)
2526
2689
  return null;
@@ -2551,6 +2714,26 @@ class AmLyrics extends i {
2551
2714
  }
2552
2715
  this.preActiveLineElements = keptLines;
2553
2716
  }
2717
+ setBackgroundExpandedLine(lineElement) {
2718
+ const target = lineElement &&
2719
+ !lineElement.classList.contains('lyrics-gap') &&
2720
+ lineElement.querySelector('.background-vocal-container')
2721
+ ? lineElement
2722
+ : null;
2723
+ if (this.backgroundExpandedLine === target) {
2724
+ if (target && !target.classList.contains('bg-expanded')) {
2725
+ target.classList.add('bg-expanded');
2726
+ }
2727
+ return;
2728
+ }
2729
+ this.backgroundExpandedLine?.classList.remove('bg-expanded');
2730
+ this.backgroundExpandedLine = target;
2731
+ target?.classList.add('bg-expanded');
2732
+ }
2733
+ clearBackgroundExpandedLine() {
2734
+ this.backgroundExpandedLine?.classList.remove('bg-expanded');
2735
+ this.backgroundExpandedLine = null;
2736
+ }
2554
2737
  getPrimaryActiveLineIndex(activeIndices) {
2555
2738
  if (activeIndices.length === 0)
2556
2739
  return null;
@@ -2740,9 +2923,10 @@ class AmLyrics extends i {
2740
2923
  const activeLines = [];
2741
2924
  for (let i = 0; i < this.lyrics.length; i += 1) {
2742
2925
  const line = this.lyrics[i];
2926
+ const highlightEndTime = this.getLineHighlightEndTime(i);
2743
2927
  if (line.timestamp > time)
2744
2928
  break;
2745
- if (time >= line.timestamp && time < line.endtime) {
2929
+ if (time >= line.timestamp && time < highlightEndTime) {
2746
2930
  activeLines.push(i);
2747
2931
  }
2748
2932
  }
@@ -2967,6 +3151,7 @@ class AmLyrics extends i {
2967
3151
  this.lastPrimaryActiveLine = null;
2968
3152
  this.activeLineIds.clear();
2969
3153
  this.animatingLines = [];
3154
+ this.setBackgroundExpandedLine(null);
2970
3155
  // Find the clicked line element and scroll to it with forceScroll (like YouLyPlus)
2971
3156
  // Timestamps are already in milliseconds — match the data-start-time attribute directly
2972
3157
  const clickedLineElement = this.lyricsContainer?.querySelector(`.lyrics-line[data-start-time="${line.text[0]?.timestamp || 0}"]`);
@@ -2983,6 +3168,7 @@ class AmLyrics extends i {
2983
3168
  this.isClickSeeking = false;
2984
3169
  }, 800);
2985
3170
  this.scrollToActiveLineYouLy(clickedLineElement, true);
3171
+ this.setBackgroundExpandedLine(clickedLineElement);
2986
3172
  }
2987
3173
  const event = new CustomEvent('line-click', {
2988
3174
  detail: {
@@ -3328,21 +3514,259 @@ class AmLyrics extends i {
3328
3514
  }
3329
3515
  /**
3330
3516
  * Update syllable highlight animation - apply CSS wipe animation
3331
- * (Exact copy from YouLyPlus _updateSyllableAnimation)
3332
3517
  */
3518
+ static clamp(value, min, max) {
3519
+ return Math.min(max, Math.max(min, value));
3520
+ }
3521
+ static getVisibleCharacterCount(element) {
3522
+ const attrLength = parseFloat(element.getAttribute('data-word-length') || '');
3523
+ if (Number.isFinite(attrLength) && attrLength > 0)
3524
+ return attrLength;
3525
+ return (element.textContent || '').replace(/\s/g, '').length;
3526
+ }
3527
+ static getLongWordWipeScale(charCount) {
3528
+ if (charCount <= 6)
3529
+ return 1;
3530
+ return (1 +
3531
+ AmLyrics.clamp((charCount - 6) / 10, 0, 1) * LONG_WORD_WIPE_EXTRA_RATIO);
3532
+ }
3533
+ static applyWipeShape(element, charCount) {
3534
+ const extra = AmLyrics.clamp((charCount - 6) / 10, 0, 1) * LONG_WORD_WIPE_EXTRA_EM;
3535
+ const width = BASE_WIPE_GRADIENT_EM + extra;
3536
+ element.style.setProperty('--wipe-gradient-width', `${width.toFixed(3)}em`);
3537
+ element.style.setProperty('--wipe-gradient-half', `${(width / 2).toFixed(3)}em`);
3538
+ }
3539
+ static ensureWordWipeGeometry(charSpans, charCount) {
3540
+ if (charSpans.length === 0)
3541
+ return;
3542
+ const approxWidthCh = Math.max(1, charCount || charSpans.length);
3543
+ charSpans.forEach((span, index) => {
3544
+ if (!span.style.getPropertyValue('--word-wipe-width')) {
3545
+ span.style.setProperty('--word-wipe-width', `${approxWidthCh}ch`);
3546
+ }
3547
+ if (!span.style.getPropertyValue('--char-wipe-position')) {
3548
+ const startPct = Number.parseFloat(span.dataset.wipeStart || `${index / Math.max(1, charSpans.length)}`);
3549
+ span.style.setProperty('--char-wipe-position', `${-(AmLyrics.clamp(startPct, 0, 1) * approxWidthCh)}ch`);
3550
+ }
3551
+ });
3552
+ }
3553
+ static clearPreHighlight(syllable) {
3554
+ const target = syllable;
3555
+ target.classList.remove('pre-highlight');
3556
+ target.style.removeProperty('--pre-wipe-duration');
3557
+ target.style.removeProperty('--pre-wipe-delay');
3558
+ target.style.animation = '';
3559
+ target
3560
+ .querySelectorAll('.pre-wipe-lead')
3561
+ .forEach(element => AmLyrics.clearPreWipeLead(element));
3562
+ }
3563
+ static clearPreWipeLead(element) {
3564
+ element.classList.remove('pre-wipe-lead');
3565
+ element.style.removeProperty('--pre-wipe-duration');
3566
+ element.style.removeProperty('--pre-wipe-delay');
3567
+ }
3568
+ static hasTextBoundaryAfter(syllable) {
3569
+ return /\s$/.test(syllable.textContent || '');
3570
+ }
3571
+ static getSyllableWordIndex(syllable) {
3572
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3573
+ const virtualWordId = wordElement?.dataset.virtualWordId;
3574
+ if (virtualWordId) {
3575
+ return `virtual:${virtualWordId}`;
3576
+ }
3577
+ const virtualWordStart = wordElement?.dataset.virtualWordStart;
3578
+ const virtualWordEnd = wordElement?.dataset.virtualWordEnd;
3579
+ if (virtualWordStart || virtualWordEnd) {
3580
+ return `virtual:${virtualWordStart || ''}:${virtualWordEnd || ''}`;
3581
+ }
3582
+ return (syllable.getAttribute('data-word-index') ||
3583
+ syllable.getAttribute('data-syllable-index') ||
3584
+ '');
3585
+ }
3586
+ static getNextWordSyllable(syllables, index) {
3587
+ const current = syllables[index];
3588
+ const currentWordIndex = AmLyrics.getSyllableWordIndex(current);
3589
+ const previousSyllable = current;
3590
+ for (let i = index + 1; i < syllables.length; i += 1) {
3591
+ const candidate = syllables[i];
3592
+ if (candidate.classList.contains('transliteration')) {
3593
+ // eslint-disable-next-line no-continue
3594
+ continue;
3595
+ }
3596
+ const candidateWordIndex = AmLyrics.getSyllableWordIndex(candidate);
3597
+ if (candidateWordIndex === currentWordIndex ||
3598
+ !AmLyrics.hasTextBoundaryAfter(previousSyllable)) {
3599
+ return null;
3600
+ }
3601
+ return candidate;
3602
+ }
3603
+ return null;
3604
+ }
3605
+ static getPreviousNonTransliterationSyllable(syllables, index) {
3606
+ for (let i = index - 1; i >= 0; i -= 1) {
3607
+ const candidate = syllables[i];
3608
+ if (!candidate.classList.contains('transliteration')) {
3609
+ return candidate;
3610
+ }
3611
+ }
3612
+ return null;
3613
+ }
3614
+ static getRenderedWordSyllables(syllable) {
3615
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3616
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
3617
+ const wordSyllables = wordElements.flatMap(element => Array.from(element.querySelectorAll('.lyrics-syllable')));
3618
+ return wordSyllables.filter(wordSyllable => !wordSyllable.classList.contains('transliteration'));
3619
+ }
3620
+ static getWordElementForSyllable(syllable) {
3621
+ return syllable.parentElement?.parentElement;
3622
+ }
3623
+ static getWordPreWipeKey(syllable) {
3624
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3625
+ return (wordElement?.dataset.virtualWordId ||
3626
+ `${syllable.getAttribute('data-start-time') || ''}:${AmLyrics.getSyllableWordIndex(syllable)}`);
3627
+ }
3628
+ static isPreWipeArmed(syllable) {
3629
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3630
+ const target = wordElement;
3631
+ return Boolean(target?._wordPreWipeKey === AmLyrics.getWordPreWipeKey(syllable));
3632
+ }
3633
+ static applyWordPreWipe(nextSyllable, wordSyllables, currentTimeMs, preWipeStartMs, preWipeDurationMs) {
3634
+ if (AmLyrics.isPreWipeArmed(nextSyllable))
3635
+ return;
3636
+ const wordElement = AmLyrics.getWordElementForSyllable(nextSyllable);
3637
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
3638
+ const charSpans = AmLyrics.getCachedVirtualWordCharSpans(wordElement, []);
3639
+ const elapsedPreWipe = currentTimeMs - preWipeStartMs;
3640
+ const charCount = charSpans.length ||
3641
+ wordSyllables.reduce((total, wordSyllable) => total + AmLyrics.getVisibleCharacterCount(wordSyllable), 0) ||
3642
+ AmLyrics.getVisibleCharacterCount(nextSyllable);
3643
+ AmLyrics.ensureWordWipeGeometry(charSpans, charCount);
3644
+ const leadChar = charSpans[0];
3645
+ const leadCharSyllable = leadChar?.closest('.lyrics-syllable');
3646
+ const preWipeSyllable = leadCharSyllable || wordSyllables[0] || nextSyllable;
3647
+ AmLyrics.applyWipeShape(preWipeSyllable, charCount);
3648
+ preWipeSyllable.style.setProperty('--pre-wipe-duration', `${preWipeDurationMs}ms`);
3649
+ preWipeSyllable.style.setProperty('--pre-wipe-delay', `${-elapsedPreWipe}ms`);
3650
+ preWipeSyllable.classList.add('pre-highlight');
3651
+ let leadSpans = charSpans;
3652
+ if (leadSpans.length === 0 && leadChar) {
3653
+ leadSpans = [leadChar];
3654
+ }
3655
+ leadSpans.forEach(span => {
3656
+ AmLyrics.applyWipeShape(span, charCount);
3657
+ span.style.setProperty('--pre-wipe-duration', `${preWipeDurationMs}ms`);
3658
+ span.style.setProperty('--pre-wipe-delay', `${-elapsedPreWipe}ms`);
3659
+ span.classList.add('pre-wipe-lead');
3660
+ });
3661
+ wordElements.forEach(element => {
3662
+ const target = element;
3663
+ target._wordPreWipeKey = AmLyrics.getWordPreWipeKey(nextSyllable);
3664
+ });
3665
+ }
3666
+ static maybePreWipeNextWord(syllables, index, currentTimeMs, currentEndTimeMs) {
3667
+ const syllable = syllables[index];
3668
+ if (syllable.classList.contains('line-synced') ||
3669
+ syllable.classList.contains('transliteration') ||
3670
+ syllable.closest('.lyrics-gap')) {
3671
+ return;
3672
+ }
3673
+ const currentWordReady = syllable.classList.contains('finished') ||
3674
+ currentTimeMs >= currentEndTimeMs - WORD_PRE_WIPE_HANDOFF_LEAD_MS;
3675
+ if (!currentWordReady) {
3676
+ return;
3677
+ }
3678
+ const nextSyllable = AmLyrics.getNextWordSyllable(syllables, index);
3679
+ if (!nextSyllable ||
3680
+ nextSyllable.classList.contains('line-synced') ||
3681
+ nextSyllable.classList.contains('transliteration') ||
3682
+ nextSyllable.closest('.lyrics-gap') ||
3683
+ nextSyllable.classList.contains('highlight') ||
3684
+ nextSyllable.classList.contains('finished')) {
3685
+ return;
3686
+ }
3687
+ const nextStartTimeMs = nextSyllable._cachedStartTime;
3688
+ if (!Number.isFinite(nextStartTimeMs))
3689
+ return;
3690
+ const gapMs = nextStartTimeMs - currentEndTimeMs;
3691
+ if (gapMs > NEXT_WORD_PRE_WIPE_MAX_GAP_MS || gapMs < -50) {
3692
+ return;
3693
+ }
3694
+ const nextWordSyllables = AmLyrics.getRenderedWordSyllables(nextSyllable);
3695
+ const preWipeSyllables = nextWordSyllables.length > 0 ? nextWordSyllables : [nextSyllable];
3696
+ const wordElement = AmLyrics.getWordElementForSyllable(nextSyllable);
3697
+ const wordCharSpans = AmLyrics.getCachedVirtualWordCharSpans(wordElement, []);
3698
+ const charCount = wordCharSpans.length ||
3699
+ preWipeSyllables.reduce((total, wordSyllable) => total + AmLyrics.getVisibleCharacterCount(wordSyllable), 0);
3700
+ if (charCount <= 0)
3701
+ return;
3702
+ const preWipeDuration = AmLyrics.clamp(64 + charCount * 9, NEXT_WORD_PRE_WIPE_MIN_DURATION_MS, NEXT_WORD_PRE_WIPE_MAX_DURATION_MS);
3703
+ const preWipeStart = Math.max(nextStartTimeMs - preWipeDuration, currentEndTimeMs - WORD_PRE_WIPE_HANDOFF_LEAD_MS);
3704
+ if (currentTimeMs < preWipeStart || currentTimeMs >= nextStartTimeMs) {
3705
+ return;
3706
+ }
3707
+ AmLyrics.applyWordPreWipe(nextSyllable, preWipeSyllables, currentTimeMs, preWipeStart, preWipeDuration);
3708
+ }
3709
+ static getCachedCharSpans(element) {
3710
+ const cacheTarget = element;
3711
+ if (!cacheTarget._cachedCharSpans) {
3712
+ cacheTarget._cachedCharSpans = Array.from(element.querySelectorAll('span.char'));
3713
+ }
3714
+ return cacheTarget._cachedCharSpans;
3715
+ }
3716
+ static getCachedVirtualWordElements(wordElement) {
3717
+ if (!wordElement)
3718
+ return [];
3719
+ const cacheTarget = wordElement;
3720
+ if (cacheTarget._cachedVirtualWordElements) {
3721
+ return cacheTarget._cachedVirtualWordElements;
3722
+ }
3723
+ const { virtualWordId } = wordElement.dataset;
3724
+ let wordElements = [wordElement];
3725
+ if (virtualWordId && wordElement.parentElement) {
3726
+ wordElements = Array.from(wordElement.parentElement.querySelectorAll('.lyrics-word')).filter(el => el.dataset.virtualWordId === virtualWordId);
3727
+ }
3728
+ wordElements.forEach(element => {
3729
+ const target = element;
3730
+ target._cachedVirtualWordElements = wordElements;
3731
+ });
3732
+ return wordElements;
3733
+ }
3734
+ static getCachedVirtualWordCharSpans(wordElement, fallbackCharSpans) {
3735
+ if (!wordElement)
3736
+ return fallbackCharSpans;
3737
+ const cacheTarget = wordElement;
3738
+ if (cacheTarget._cachedVirtualWordCharSpans) {
3739
+ return cacheTarget._cachedVirtualWordCharSpans;
3740
+ }
3741
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
3742
+ const charSpans = wordElements.flatMap(word => Array.from(word.querySelectorAll('span.char')));
3743
+ const result = charSpans.length > 0 ? charSpans : fallbackCharSpans;
3744
+ wordElements.forEach(element => {
3745
+ const target = element;
3746
+ target._cachedVirtualWordCharSpans = result;
3747
+ });
3748
+ return result;
3749
+ }
3333
3750
  static updateSyllableAnimation(syllable, elapsedTimeMs = 0) {
3334
3751
  if (syllable.classList.contains('highlight'))
3335
3752
  return;
3336
3753
  const { classList } = syllable;
3754
+ const hadPreHighlight = classList.contains('pre-highlight');
3337
3755
  const isRTL = classList.contains('rtl-text');
3338
- const charSpans = Array.from(syllable.querySelectorAll('span.char'));
3756
+ const charSpans = AmLyrics.getCachedCharSpans(syllable);
3339
3757
  const wordElement = syllable.parentElement?.parentElement; // syllable-wrap -> word
3340
- const allWordCharSpans = wordElement
3341
- ? Array.from(wordElement.querySelectorAll('span.char'))
3342
- : [];
3343
- const isGrowable = wordElement?.classList.contains('growable');
3344
- const isCharRise = wordElement?.classList.contains('char-rise');
3758
+ const typedWordElement = wordElement;
3759
+ const allWordElements = AmLyrics.getCachedVirtualWordElements(typedWordElement);
3760
+ const allWordCharSpans = AmLyrics.getCachedVirtualWordCharSpans(typedWordElement, charSpans);
3761
+ const isGrowable = typedWordElement?.classList.contains('growable');
3762
+ const isCharRise = typedWordElement?.classList.contains('char-rise');
3763
+ const isCharDrag = typedWordElement?.classList.contains('char-drag');
3345
3764
  const isFirstSyllable = syllable.getAttribute('data-syllable-index') === '0';
3765
+ const syllableStartMs = parseFloat(syllable.getAttribute('data-start-time') || '0');
3766
+ const virtualWordStartMs = parseFloat(typedWordElement?.dataset.virtualWordStart || '');
3767
+ const isFirstInVirtualWord = isFirstSyllable &&
3768
+ (!Number.isFinite(virtualWordStartMs) ||
3769
+ Math.abs(syllableStartMs - virtualWordStartMs) < 0.5);
3346
3770
  const isFirstInContainer = isFirstSyllable; // Simplified
3347
3771
  const isGap = syllable.closest('.lyrics-gap') !== null;
3348
3772
  // Get duration from data attribute
@@ -3350,11 +3774,14 @@ class AmLyrics extends i {
3350
3774
  const wordDurationMs = parseFloat(syllable.getAttribute('data-word-duration') ||
3351
3775
  syllable.getAttribute('data-duration') ||
3352
3776
  '0') || syllableDurationMs;
3353
- // Use a Map to collect animations like YouLyPlus
3777
+ const wordElapsedTimeMs = Number.isFinite(virtualWordStartMs)
3778
+ ? elapsedTimeMs + (syllableStartMs - virtualWordStartMs)
3779
+ : elapsedTimeMs;
3780
+ const charWipeDurationMs = Math.max(wordDurationMs, syllableDurationMs);
3354
3781
  const charAnimationsMap = new Map();
3355
3782
  const styleUpdates = [];
3356
3783
  // Step 1: Grow Pass
3357
- if (isGrowable && isFirstSyllable && allWordCharSpans.length > 0) {
3784
+ if (isGrowable && isFirstInVirtualWord && allWordCharSpans.length > 0) {
3358
3785
  const finalDuration = wordDurationMs;
3359
3786
  const baseDelayPerChar = finalDuration * 0.09;
3360
3787
  const growDurationMs = finalDuration * 1.5;
@@ -3388,7 +3815,7 @@ class AmLyrics extends i {
3388
3815
  });
3389
3816
  });
3390
3817
  }
3391
- if (isCharRise && isFirstSyllable && allWordCharSpans.length > 0) {
3818
+ if (isCharRise && isFirstInVirtualWord && allWordCharSpans.length > 0) {
3392
3819
  const finalDuration = Math.max(wordDurationMs, syllableDurationMs);
3393
3820
  const baseDelayPerChar = finalDuration * 0.09;
3394
3821
  const riseDurationMs = finalDuration * 1.5;
@@ -3398,16 +3825,60 @@ class AmLyrics extends i {
3398
3825
  charAnimationsMap.set(span, `rise-char ${riseDurationMs}ms ease-in-out ${riseDelay}ms forwards`);
3399
3826
  });
3400
3827
  }
3828
+ if (isCharDrag && isFirstInVirtualWord && allWordCharSpans.length > 0) {
3829
+ const finalDuration = Math.max(wordDurationMs, syllableDurationMs);
3830
+ const baseDelayPerChar = AmLyrics.clamp(finalDuration * 0.15, 64, 118);
3831
+ const dragDurationMs = AmLyrics.clamp(finalDuration * 0.82, 560, 900);
3832
+ allWordCharSpans.forEach(span => {
3833
+ const charIndex = parseFloat(span.dataset.syllableCharIndex || '0');
3834
+ const dragDelay = baseDelayPerChar * charIndex;
3835
+ charAnimationsMap.set(span, `drag-char ${dragDurationMs}ms ease ${dragDelay}ms forwards`);
3836
+ });
3837
+ }
3401
3838
  // Step 2: Wipe Pass
3402
3839
  if (charSpans.length > 0) {
3403
- charSpans.forEach((span, charIndex) => {
3840
+ const wipeCharCount = allWordCharSpans.length ||
3841
+ charSpans.length ||
3842
+ AmLyrics.getVisibleCharacterCount(syllable);
3843
+ const wipeScale = AmLyrics.getLongWordWipeScale(wipeCharCount);
3844
+ AmLyrics.applyWipeShape(syllable, wipeCharCount);
3845
+ AmLyrics.ensureWordWipeGeometry(allWordCharSpans, wipeCharCount);
3846
+ allWordCharSpans.forEach(span => AmLyrics.applyWipeShape(span, wipeCharCount));
3847
+ const hasWordLevelWipe = !isFirstInVirtualWord &&
3848
+ (Boolean(typedWordElement?._wordWipeStarted) ||
3849
+ allWordCharSpans.some(span => span.style.animation.includes('wipe')));
3850
+ let charSpansToAnimate = charSpans;
3851
+ if (isFirstInVirtualWord) {
3852
+ charSpansToAnimate = allWordCharSpans;
3853
+ }
3854
+ else if (hasWordLevelWipe) {
3855
+ charSpansToAnimate = [];
3856
+ }
3857
+ if (charSpansToAnimate.length > 0 && allWordElements.length > 0) {
3858
+ allWordElements.forEach(element => {
3859
+ const target = element;
3860
+ target._wordWipeStarted = true;
3861
+ target._wordPreWipeKey = undefined;
3862
+ });
3863
+ }
3864
+ charSpansToAnimate.forEach((span, charIndex) => {
3404
3865
  const startPct = parseFloat(span.dataset.wipeStart || '0');
3405
3866
  const durationPct = parseFloat(span.dataset.wipeDuration || '0');
3406
- const wipeDelay = syllableDurationMs * startPct - elapsedTimeMs;
3407
- const wipeDuration = syllableDurationMs * durationPct;
3408
- const useStartAnimation = isFirstInContainer && charIndex === 0;
3867
+ const globalCharIndex = parseFloat(span.dataset.syllableCharIndex || `${charIndex}`);
3868
+ const hadCharPreWipe = span.classList.contains('pre-wipe-lead') ||
3869
+ (hadPreHighlight && globalCharIndex === 0);
3870
+ const charStartMs = charWipeDurationMs * startPct;
3871
+ const remainingWordWipeMs = Math.max(0, charWipeDurationMs - charStartMs);
3872
+ let wipeDelay = charStartMs - wordElapsedTimeMs;
3873
+ let wipeDuration = Math.min(charWipeDurationMs * durationPct * wipeScale, remainingWordWipeMs);
3874
+ const useStartAnimation = isFirstInContainer && globalCharIndex === 0 && !hadCharPreWipe;
3409
3875
  let charWipeAnimation = 'wipe';
3410
- if (useStartAnimation) {
3876
+ if (hadCharPreWipe) {
3877
+ charWipeAnimation = 'wipe-word-from-pre';
3878
+ wipeDelay = -wordElapsedTimeMs;
3879
+ wipeDuration = charWipeDurationMs;
3880
+ }
3881
+ else if (useStartAnimation) {
3411
3882
  charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
3412
3883
  }
3413
3884
  else {
@@ -3417,21 +3888,13 @@ class AmLyrics extends i {
3417
3888
  const animationParts = [];
3418
3889
  if (existingAnimation &&
3419
3890
  (existingAnimation.includes('grow-dynamic') ||
3420
- existingAnimation.includes('rise-char'))) {
3891
+ existingAnimation.includes('rise-char') ||
3892
+ existingAnimation.includes('drag-char'))) {
3421
3893
  animationParts.push(existingAnimation.split(',')[0].trim());
3422
3894
  }
3423
- if (charIndex > 0) {
3424
- const arrivalTime = (span.dataset.preWipeArrival
3425
- ? parseFloat(span.dataset.preWipeArrival)
3426
- : syllableDurationMs * startPct) - elapsedTimeMs;
3427
- const constantDuration = parseFloat(span.dataset.preWipeDuration || '100');
3428
- const animDelay = arrivalTime - constantDuration;
3429
- if (constantDuration > 0) {
3430
- animationParts.push(`pre-wipe-char ${constantDuration}ms linear ${animDelay}ms forwards`);
3431
- }
3432
- }
3433
3895
  if (wipeDuration > 0) {
3434
- animationParts.push(`${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`);
3896
+ const wipeFillMode = hadCharPreWipe ? 'both' : 'forwards';
3897
+ animationParts.push(`${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms ${wipeFillMode}`);
3435
3898
  }
3436
3899
  if (animationParts.length > 0) {
3437
3900
  charAnimationsMap.set(span, animationParts.join(', '));
@@ -3441,9 +3904,15 @@ class AmLyrics extends i {
3441
3904
  else {
3442
3905
  // Syllable-level wipe for regular (non-growable) words without chars
3443
3906
  const wipeRatio = parseFloat(syllable.getAttribute('data-wipe-ratio') || '1');
3444
- const visualDuration = syllableDurationMs * wipeRatio;
3907
+ const wipeCharCount = AmLyrics.getVisibleCharacterCount(syllable);
3908
+ const wipeScale = AmLyrics.getLongWordWipeScale(wipeCharCount);
3909
+ const visualDuration = syllableDurationMs * wipeRatio * wipeScale;
3910
+ AmLyrics.applyWipeShape(syllable, wipeCharCount);
3445
3911
  let wipeAnimation = 'wipe';
3446
- if (isFirstInContainer) {
3912
+ if (hadPreHighlight) {
3913
+ wipeAnimation = isRTL ? 'wipe-from-pre-rtl' : 'wipe-from-pre';
3914
+ }
3915
+ else if (isFirstInContainer) {
3447
3916
  wipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
3448
3917
  }
3449
3918
  else {
@@ -3456,16 +3925,25 @@ class AmLyrics extends i {
3456
3925
  syllable.style.animation = `${currentWipeAnimation} ${visualDuration}ms ${isGap ? 'ease-out' : 'linear'} ${-elapsedTimeMs}ms forwards`;
3457
3926
  }
3458
3927
  // --- WRITE PHASE ---
3928
+ if (allWordElements.length > 0) {
3929
+ allWordElements.forEach(element => {
3930
+ const target = element;
3931
+ target._wordPreWipeKey = undefined;
3932
+ });
3933
+ }
3459
3934
  classList.remove('pre-highlight');
3460
3935
  classList.add('highlight');
3936
+ allWordCharSpans.forEach(span => AmLyrics.clearPreWipeLead(span));
3937
+ // Apply keyframe variables before assigning animation strings so the
3938
+ // first painted frame never uses fallback transform values.
3939
+ for (const update of styleUpdates) {
3940
+ update.element.style.setProperty(update.property, update.value);
3941
+ }
3461
3942
  for (const [span, animationString] of charAnimationsMap.entries()) {
3462
3943
  span.style.willChange = 'transform';
3944
+ span.style.removeProperty('background-color');
3463
3945
  span.style.animation = animationString;
3464
3946
  }
3465
- // Apply style updates
3466
- for (const update of styleUpdates) {
3467
- update.element.style.setProperty(update.property, update.value);
3468
- }
3469
3947
  }
3470
3948
  /**
3471
3949
  * Reset syllable animation state
@@ -3489,10 +3967,19 @@ class AmLyrics extends i {
3489
3967
  el.style.animation = '';
3490
3968
  el.style.transition = 'none';
3491
3969
  el.style.backgroundColor = 'var(--lyplus-text-secondary)';
3970
+ AmLyrics.clearPreWipeLead(el);
3492
3971
  }
3493
3972
  // Immediately remove all state classes
3494
3973
  syllable.classList.remove('highlight', 'finished', 'pre-highlight', 'cleanup');
3495
3974
  }
3975
+ static resetWordAnimationState(line) {
3976
+ const wordElements = line.querySelectorAll('.lyrics-word');
3977
+ wordElements.forEach(wordElement => {
3978
+ const target = wordElement;
3979
+ target._wordPreWipeKey = undefined;
3980
+ target._wordWipeStarted = false;
3981
+ });
3982
+ }
3496
3983
  /**
3497
3984
  * Reset all syllables in a line — batches deferred cleanup into a single rAF
3498
3985
  */
@@ -3500,6 +3987,7 @@ class AmLyrics extends i {
3500
3987
  if (!line)
3501
3988
  return;
3502
3989
  line.classList.remove('persist-highlight');
3990
+ AmLyrics.resetWordAnimationState(line);
3503
3991
  // eslint-disable-next-line no-param-reassign
3504
3992
  line._cachedSyllableElements = null;
3505
3993
  const syllables = line.getElementsByClassName('lyrics-syllable');
@@ -3531,6 +4019,7 @@ class AmLyrics extends i {
3531
4019
  if (!line)
3532
4020
  return;
3533
4021
  line.classList.remove('persist-highlight');
4022
+ AmLyrics.resetWordAnimationState(line);
3534
4023
  const syllables = line.getElementsByClassName('lyrics-syllable');
3535
4024
  for (let i = 0; i < syllables.length; i += 1) {
3536
4025
  const s = syllables[i];
@@ -3548,6 +4037,7 @@ class AmLyrics extends i {
3548
4037
  el.style.removeProperty('background-color');
3549
4038
  el.style.removeProperty('transition');
3550
4039
  el.style.removeProperty('filter');
4040
+ AmLyrics.clearPreWipeLead(el);
3551
4041
  }
3552
4042
  }
3553
4043
  }
@@ -3584,19 +4074,26 @@ class AmLyrics extends i {
3584
4074
  syllable.style.animation = '';
3585
4075
  syllable.style.removeProperty('--pre-wipe-duration');
3586
4076
  syllable.style.removeProperty('--pre-wipe-delay');
4077
+ syllable.style.removeProperty('background-color');
4078
+ AmLyrics.applyWipeShape(syllable, AmLyrics.getVisibleCharacterCount(syllable));
3587
4079
  const chars = syllable.querySelectorAll('span.char');
3588
4080
  for (let ci = 0; ci < chars.length; ci += 1) {
3589
4081
  const charEl = chars[ci];
3590
4082
  const currentAnim = charEl.style.animation || '';
3591
4083
  if (currentAnim.includes('grow-dynamic') ||
3592
- currentAnim.includes('rise-char')) {
4084
+ currentAnim.includes('rise-char') ||
4085
+ currentAnim.includes('drag-char')) {
3593
4086
  const parts = currentAnim.split(',').map(p => p.trim());
3594
- const transformAnim = parts.find(p => p.includes('grow-dynamic') || p.includes('rise-char'));
4087
+ const transformAnim = parts.find(p => p.includes('grow-dynamic') ||
4088
+ p.includes('rise-char') ||
4089
+ p.includes('drag-char'));
3595
4090
  charEl.style.animation = transformAnim || '';
3596
4091
  }
3597
4092
  else {
3598
4093
  charEl.style.animation = '';
3599
4094
  }
4095
+ charEl.style.backgroundColor = 'var(--lyplus-text-primary)';
4096
+ AmLyrics.clearPreWipeLead(charEl);
3600
4097
  }
3601
4098
  }
3602
4099
  }
@@ -3637,14 +4134,16 @@ class AmLyrics extends i {
3637
4134
  // Early exit check
3638
4135
  if (!(currentTimeMs < startTime - 1000 && !hasActiveState)) {
3639
4136
  let preHighlightReset = false;
3640
- // Pre-highlight reset logic
3641
- if (hasPreHighlight && i > 0) {
3642
- const prevSyllable = syllables[i - 1];
3643
- if (!prevSyllable.classList.contains('highlight')) {
3644
- classList.remove('pre-highlight');
3645
- syllable.style.removeProperty('--pre-wipe-duration');
3646
- syllable.style.removeProperty('--pre-wipe-delay');
3647
- syllable.style.animation = '';
4137
+ // Before the syllable starts, pre-highlight only belongs beside a
4138
+ // previous active word. Once the syllable starts, updateSyllableAnimation
4139
+ // consumes the class so the actual wipe can continue from the pre-wipe
4140
+ // pose instead of restarting from the beginning.
4141
+ if (hasPreHighlight && currentTimeMs < startTime) {
4142
+ const prevSyllable = AmLyrics.getPreviousNonTransliterationSyllable(syllables, i);
4143
+ const previousCarriesHighlight = prevSyllable?.classList.contains('highlight') ||
4144
+ prevSyllable?.classList.contains('finished');
4145
+ if (!previousCarriesHighlight) {
4146
+ AmLyrics.clearPreHighlight(syllable);
3648
4147
  preHighlightReset = true;
3649
4148
  }
3650
4149
  }
@@ -3672,6 +4171,7 @@ class AmLyrics extends i {
3672
4171
  // Not yet started
3673
4172
  AmLyrics.resetSyllable(syllable);
3674
4173
  }
4174
+ AmLyrics.maybePreWipeNextWord(syllables, i, currentTimeMs, endTime);
3675
4175
  }
3676
4176
  }
3677
4177
  }
@@ -3981,6 +4481,9 @@ class AmLyrics extends i {
3981
4481
  data-end-time="${endTimeMs}"
3982
4482
  data-duration="${durationMs}"
3983
4483
  data-syllable-index="${syllableIndex}"
4484
+ data-word-index="${syllableIndex}"
4485
+ data-word-length="${syllable.text.replace(/\s/g, '')
4486
+ .length}"
3984
4487
  data-wipe-ratio="1"
3985
4488
  >${syllable.text}</span
3986
4489
  >${bgRomanizedText}</span
@@ -4002,11 +4505,13 @@ class AmLyrics extends i {
4002
4505
  const groupGrowable = lineData?.groupGrowable ?? [];
4003
4506
  const groupGlowing = lineData?.groupGlowing ?? [];
4004
4507
  const groupCharRise = lineData?.groupCharRise ?? [];
4508
+ const groupCharDrag = lineData?.groupCharDrag ?? [];
4005
4509
  const vwFullText = lineData?.vwFullText ?? [];
4006
4510
  const vwFullDuration = lineData?.vwFullDuration ?? [];
4007
4511
  const vwCharOffset = lineData?.vwCharOffset ?? [];
4512
+ const vwStartMs = lineData?.vwStartMs ?? [];
4513
+ const vwEndMs = lineData?.vwEndMs ?? [];
4008
4514
  const lineIsRTL = lineData?.lineIsRTL ?? false;
4009
- // Create main vocals using YouLyPlus syllable structure
4010
4515
  const mainVocalElement = b `<p
4011
4516
  class="main-vocal-container ${lineIsRTL ? 'rtl-text' : ''}"
4012
4517
  >
@@ -4014,18 +4519,23 @@ class AmLyrics extends i {
4014
4519
  const isGrowable = groupGrowable[groupIdx];
4015
4520
  const isGlowing = groupGlowing[groupIdx];
4016
4521
  const isCharRise = groupCharRise[groupIdx];
4017
- const isAnimatedByChar = isGrowable || isCharRise;
4522
+ const isCharDrag = groupCharDrag[groupIdx];
4523
+ const isAnimatedByChar = isGrowable || isCharRise || isCharDrag;
4018
4524
  const groupLineSynced = group.some(s => s.lineSynced);
4019
4525
  const wordText = isAnimatedByChar ? vwFullText[groupIdx] : '';
4020
4526
  const wordDuration = isAnimatedByChar
4021
4527
  ? vwFullDuration[groupIdx]
4022
4528
  : 0;
4023
- const wordNumChars = wordText.length;
4529
+ const wordNumChars = wordText.replace(/\s/g, '').length;
4024
4530
  const groupCharOffset = isAnimatedByChar
4025
4531
  ? vwCharOffset[groupIdx]
4026
4532
  : 0;
4533
+ const virtualWordId = `${lineIndex}:${vwStartMs[groupIdx]}:${vwEndMs[groupIdx]}`;
4534
+ const virtualWordStart = vwStartMs[groupIdx];
4535
+ const virtualWordEnd = vwEndMs[groupIdx];
4027
4536
  let sylCharAccumulator = 0;
4028
4537
  const groupText = group.map(s => s.text).join('');
4538
+ const visibleWordLength = groupText.replace(/\s/g, '').length;
4029
4539
  const shouldAllowBreak = groupText.trim().length >= 16 ||
4030
4540
  /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(groupText);
4031
4541
  // Calculate dynamic rise duration based on the audio duration of the word
@@ -4037,9 +4547,12 @@ class AmLyrics extends i {
4037
4547
  return b `<span
4038
4548
  class="lyrics-word${isGrowable ? ' growable' : ''}${isCharRise
4039
4549
  ? ' char-rise'
4040
- : ''}${isGlowing ? ' glowing' : ''}${shouldAllowBreak
4041
- ? ' allow-break'
4042
- : ''}"
4550
+ : ''}${isCharDrag ? ' char-drag' : ''}${isGlowing
4551
+ ? ' glowing'
4552
+ : ''}${shouldAllowBreak ? ' allow-break' : ''}"
4553
+ data-virtual-word-id="${virtualWordId}"
4554
+ data-virtual-word-start="${virtualWordStart}"
4555
+ data-virtual-word-end="${virtualWordEnd}"
4043
4556
  style="--rise-duration: ${riseDuration}s"
4044
4557
  >${group.map((syllable, sylIdx) => {
4045
4558
  const startTimeMs = syllable.timestamp;
@@ -4063,13 +4576,26 @@ class AmLyrics extends i {
4063
4576
  : '';
4064
4577
  let syllableContent = text;
4065
4578
  if (isAnimatedByChar) {
4066
- let charIndexInsideSyllable = 0;
4067
4579
  const numCharsInSyllable = text.replace(/\s/g, '').length || 1;
4580
+ const hasVirtualTiming = wordDuration > 0 && Number.isFinite(virtualWordStart);
4581
+ const syllableStartRatio = hasVirtualTiming
4582
+ ? AmLyrics.clamp((startTimeMs - virtualWordStart) / wordDuration, 0, 1)
4583
+ : 0;
4584
+ const syllableDurationRatio = hasVirtualTiming
4585
+ ? AmLyrics.clamp(durationMs / wordDuration, 0, 1)
4586
+ : 1;
4587
+ let charIndexInsideSyllable = 0;
4068
4588
  syllableContent = b `${text.split('').map(char => {
4069
4589
  if (char === ' ')
4070
4590
  return ' ';
4071
4591
  const charIndexInsideWord = groupCharOffset + sylCharAccumulator;
4072
- const charStartPercentVal = charIndexInsideSyllable / numCharsInSyllable;
4592
+ const localCharIndex = charIndexInsideSyllable;
4593
+ const visibleWordChars = Math.max(1, wordNumChars);
4594
+ const charStartPercentVal = AmLyrics.clamp(syllableStartRatio +
4595
+ (localCharIndex / numCharsInSyllable) *
4596
+ syllableDurationRatio, 0, 1);
4597
+ const charDurationPercentVal = syllableDurationRatio / numCharsInSyllable ||
4598
+ 1 / visibleWordChars;
4073
4599
  sylCharAccumulator += 1;
4074
4600
  charIndexInsideSyllable += 1;
4075
4601
  const minDuration = 400;
@@ -4116,21 +4642,37 @@ class AmLyrics extends i {
4116
4642
  const normalizedGrowth = (charMaxScale - 1.0) / 0.1;
4117
4643
  const effectiveDuration = (wordDuration + durationMs * 2) / 3;
4118
4644
  const peakMultiplier = Math.min(1, Math.max(0.3, effectiveDuration / 2000));
4119
- const charTranslateYPeak = -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
4645
+ const baseTranslateYPeak = -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
4120
4646
  const position = (charIndexInsideWord + 0.5) / wordNumChars;
4121
4647
  const horizontalOffset = (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25);
4648
+ const isDragMotion = isCharDrag;
4649
+ let charTranslateYPeak = baseTranslateYPeak;
4650
+ if (isCharRise) {
4651
+ charTranslateYPeak = 0;
4652
+ }
4653
+ else if (isDragMotion) {
4654
+ charTranslateYPeak = -0.78;
4655
+ }
4656
+ let motionHorizontalOffset = horizontalOffset;
4657
+ if (isCharRise) {
4658
+ motionHorizontalOffset = 0;
4659
+ }
4660
+ else if (isDragMotion) {
4661
+ motionHorizontalOffset = 0;
4662
+ }
4122
4663
  return b `<span
4123
4664
  class="char"
4124
4665
  data-char-index="${charIndexInsideWord}"
4125
4666
  data-syllable-char-index="${charIndexInsideWord}"
4126
4667
  data-wipe-start="${charStartPercentVal.toFixed(4)}"
4127
- data-wipe-duration="${(1 / numCharsInSyllable).toFixed(4)}"
4668
+ data-wipe-duration="${charDurationPercentVal.toFixed(4)}"
4128
4669
  data-horizontal-offset="${horizontalOffset.toFixed(2)}"
4129
4670
  data-max-scale="${charMaxScale.toFixed(3)}"
4130
4671
  data-matrix-scale="${(charMaxScale * 0.98).toFixed(3)}"
4131
- data-char-offset-x="${(horizontalOffset * 0.98).toFixed(2)}"
4672
+ data-char-offset-x="${(motionHorizontalOffset * 0.98).toFixed(2)}"
4132
4673
  data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
4133
4674
  data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
4675
+ style="--word-wipe-width: ${visibleWordChars}ch; --char-wipe-position: -${charIndexInsideWord}ch"
4134
4676
  >${char}</span
4135
4677
  >`;
4136
4678
  })}`;
@@ -4148,6 +4690,8 @@ class AmLyrics extends i {
4148
4690
  data-duration="${durationMs}"
4149
4691
  data-word-duration="${wordDuration}"
4150
4692
  data-syllable-index="${sylIdx}"
4693
+ data-word-index="${groupIdx}"
4694
+ data-word-length="${visibleWordLength}"
4151
4695
  data-wipe-ratio="1"
4152
4696
  >${syllableContent}</span
4153
4697
  >${romanizedText}</span
@@ -4380,13 +4924,14 @@ class AmLyrics extends i {
4380
4924
  <button
4381
4925
  class="download-button source-switch-btn"
4382
4926
  title="Switch Lyrics Source"
4383
- style="font-family: inherit; font-size: 11px; padding: 2px 6px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.2); background: transparent; cursor: pointer; color: #aaa; display: inline-flex; align-items: center;"
4384
4927
  @click=${this.switchSource}
4385
4928
  ?disabled=${this.isFetchingAlternatives}
4386
4929
  >
4387
4930
  <svg
4388
- class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
4389
- style="margin-right: 4px;"
4931
+ class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up ${this
4932
+ .isFetchingAlternatives
4933
+ ? 'is-loading'
4934
+ : ''}"
4390
4935
  xmlns="http://www.w3.org/2000/svg"
4391
4936
  width="12"
4392
4937
  height="12"
@@ -4444,9 +4989,6 @@ class AmLyrics extends i {
4444
4989
  }
4445
4990
  }
4446
4991
  AmLyrics.styles = i$3 `
4447
- /* ==========================================================================
4448
- YOULYPLUS-INSPIRED STYLING - Design Tokens & Variables
4449
- ========================================================================== */
4450
4992
  :host {
4451
4993
  --lyplus-lyrics-palette: var(
4452
4994
  --am-lyrics-highlight-color,
@@ -4475,6 +5017,8 @@ AmLyrics.styles = i$3 `
4475
5017
  --lyplus-blur-amount: 0.07em;
4476
5018
  --lyplus-blur-amount-near: 0.035em;
4477
5019
  --lyplus-fade-gap-timing-function: ease-out;
5020
+ --wipe-gradient-width: 0.75em;
5021
+ --wipe-gradient-half: 0.375em;
4478
5022
 
4479
5023
  --lyrics-scroll-padding-top: 25%;
4480
5024
 
@@ -4502,6 +5046,9 @@ AmLyrics.styles = i$3 `
4502
5046
  max-height: 100vh;
4503
5047
  overflow-y: auto;
4504
5048
  -webkit-overflow-scrolling: touch;
5049
+ -webkit-touch-callout: none;
5050
+ -webkit-user-select: none;
5051
+ user-select: none;
4505
5052
  box-sizing: border-box;
4506
5053
  scrollbar-width: none;
4507
5054
  overflow-anchor: none;
@@ -4598,17 +5145,15 @@ AmLyrics.styles = i$3 `
4598
5145
 
4599
5146
  .background-vocal-container {
4600
5147
  max-height: 0;
4601
- overflow: visible;
5148
+ overflow: hidden;
4602
5149
  opacity: 0;
4603
5150
  font-size: var(--lyplus-font-size-subtext);
4604
5151
  line-height: 1.15;
4605
5152
  color: color-mix(in srgb, var(--lyplus-text-secondary) 80%, transparent);
4606
- /* Fast exit (0.25 s) so bg vocals collapse quickly and feel snappy.
4607
- The scroll takes ~0.4 s; finishing the collapse first prevents
4608
- the container from trailing behind the motion. */
4609
5153
  transition:
4610
- max-height 250ms cubic-bezier(0.41, 0, 0.12, 0.99),
4611
- opacity 250ms cubic-bezier(0.41, 0, 0.12, 0.99);
5154
+ max-height var(--scroll-duration, 400ms)
5155
+ cubic-bezier(0.41, 0, 0.12, 0.99),
5156
+ opacity var(--scroll-duration, 400ms) cubic-bezier(0.41, 0, 0.12, 0.99);
4612
5157
  margin: 0;
4613
5158
  pointer-events: none;
4614
5159
  }
@@ -4617,7 +5162,8 @@ AmLyrics.styles = i$3 `
4617
5162
  display: block;
4618
5163
  padding-top: 0;
4619
5164
  padding-bottom: 0;
4620
- transition: padding-top 250ms cubic-bezier(0.41, 0, 0.12, 0.99);
5165
+ transition: padding-top var(--scroll-duration, 400ms)
5166
+ cubic-bezier(0.41, 0, 0.12, 0.99);
4621
5167
  }
4622
5168
 
4623
5169
  .lyrics-line.singer-right .background-vocal-container,
@@ -4632,16 +5178,11 @@ AmLyrics.styles = i$3 `
4632
5178
  .lyrics-line.bg-expanded .background-vocal-container {
4633
5179
  max-height: 4em;
4634
5180
  opacity: 1;
4635
- /* Slower entry (0.6 s) so bg vocals expand smoothly. */
4636
- transition:
4637
- max-height 0.6s ease,
4638
- opacity 0.6s ease;
4639
- will-change: max-height, opacity;
5181
+ will-change: opacity;
4640
5182
  }
4641
5183
 
4642
5184
  .lyrics-line.bg-expanded .background-vocal-wrap {
4643
5185
  padding-top: 0.26em;
4644
- transition: padding-top 0.6s ease;
4645
5186
  }
4646
5187
 
4647
5188
  /* --- Line States & Modifiers --- */
@@ -4789,11 +5330,22 @@ AmLyrics.styles = i$3 `
4789
5330
  white-space: nowrap;
4790
5331
  }
4791
5332
 
5333
+ .lyrics-word.char-drag {
5334
+ display: inline-block;
5335
+ vertical-align: baseline;
5336
+ white-space: nowrap;
5337
+ }
5338
+
4792
5339
  .lyrics-word.char-rise.allow-break {
4793
5340
  display: inline;
4794
5341
  white-space: normal;
4795
5342
  }
4796
5343
 
5344
+ .lyrics-word.char-drag.allow-break {
5345
+ display: inline;
5346
+ white-space: normal;
5347
+ }
5348
+
4797
5349
  .lyrics-syllable-wrap {
4798
5350
  display: inline;
4799
5351
  }
@@ -4848,44 +5400,30 @@ AmLyrics.styles = i$3 `
4848
5400
  .lyrics-line.active:not(.lyrics-gap)
4849
5401
  .lyrics-syllable.pre-highlight.no-chars {
4850
5402
  background-repeat: no-repeat;
4851
- background-image:
4852
- linear-gradient(
4853
- 90deg,
4854
- #ffffff00 0%,
4855
- var(--lyplus-text-primary, #fff) 50%,
4856
- #0000 100%
4857
- ),
4858
- linear-gradient(
4859
- 90deg,
4860
- var(--lyplus-text-primary, #fff) 100%,
4861
- #0000 100%
4862
- );
4863
- background-size:
4864
- 0.5em 100%,
4865
- 0% 100%;
4866
- background-position:
4867
- -0.5em 0%,
4868
- -0.25em 0%;
5403
+ background-image: linear-gradient(
5404
+ 90deg,
5405
+ var(--lyplus-text-primary, #fff) 0%,
5406
+ var(--lyplus-text-primary, #fff)
5407
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5408
+ #0000 100%
5409
+ );
5410
+ background-size: 0% 100%;
5411
+ background-position: left;
4869
5412
  }
4870
5413
 
4871
5414
  .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.highlight.rtl-text,
4872
5415
  .lyrics-line.active:not(.lyrics-gap)
4873
5416
  .lyrics-syllable.pre-highlight.rtl-text {
4874
5417
  direction: rtl;
4875
- background-image:
4876
- linear-gradient(
4877
- -90deg,
4878
- var(--lyplus-text-primary) 0%,
4879
- transparent 100%
4880
- ),
4881
- linear-gradient(
4882
- -90deg,
4883
- var(--lyplus-text-primary) 100%,
4884
- transparent 100%
4885
- );
4886
- background-position:
4887
- calc(100% + 0.5em) 0%,
4888
- right 0%;
5418
+ background-image: linear-gradient(
5419
+ -90deg,
5420
+ var(--lyplus-text-primary) 0%,
5421
+ var(--lyplus-text-primary)
5422
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5423
+ transparent 100%
5424
+ );
5425
+ background-size: 0% 100%;
5426
+ background-position: right 0%;
4889
5427
  }
4890
5428
 
4891
5429
  /* Background vocals: muted gray wipe instead of white.
@@ -4902,18 +5440,13 @@ AmLyrics.styles = i$3 `
4902
5440
  .lyrics-line.pre-active
4903
5441
  .background-vocal-container
4904
5442
  .lyrics-syllable.pre-highlight.no-chars {
4905
- background-image:
4906
- linear-gradient(
4907
- 90deg,
4908
- #ffffff00 0%,
4909
- color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 50%,
4910
- #0000 100%
4911
- ),
4912
- linear-gradient(
4913
- 90deg,
4914
- color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 100%,
4915
- #0000 100%
4916
- );
5443
+ background-image: linear-gradient(
5444
+ 90deg,
5445
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 0%,
5446
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888)
5447
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5448
+ #0000 100%
5449
+ );
4917
5450
  }
4918
5451
 
4919
5452
  .lyrics-line.active
@@ -4928,17 +5461,13 @@ AmLyrics.styles = i$3 `
4928
5461
  .lyrics-line.pre-active
4929
5462
  .background-vocal-container
4930
5463
  .lyrics-syllable.pre-highlight.rtl-text {
4931
- background-image:
4932
- linear-gradient(
4933
- -90deg,
4934
- color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 0%,
4935
- transparent 100%
4936
- ),
4937
- linear-gradient(
4938
- -90deg,
4939
- color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 100%,
4940
- transparent 100%
4941
- );
5464
+ background-image: linear-gradient(
5465
+ -90deg,
5466
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 0%,
5467
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888)
5468
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5469
+ transparent 100%
5470
+ );
4942
5471
  }
4943
5472
 
4944
5473
  /* Non-growable words float up with a gentle curve */
@@ -4962,6 +5491,10 @@ AmLyrics.styles = i$3 `
4962
5491
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
4963
5492
  }
4964
5493
 
5494
+ .lyrics-word.char-drag .lyrics-syllable.cleanup .char {
5495
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
5496
+ }
5497
+
4965
5498
  .lyrics-line.persist-highlight
4966
5499
  .lyrics-word.growable
4967
5500
  .lyrics-syllable.finished
@@ -4969,6 +5502,10 @@ AmLyrics.styles = i$3 `
4969
5502
  .lyrics-line.persist-highlight
4970
5503
  .lyrics-word.char-rise
4971
5504
  .lyrics-syllable.finished
5505
+ .char,
5506
+ .lyrics-line.persist-highlight
5507
+ .lyrics-word.char-drag
5508
+ .lyrics-syllable.finished
4972
5509
  .char {
4973
5510
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
4974
5511
  }
@@ -5079,70 +5616,56 @@ AmLyrics.styles = i$3 `
5079
5616
  transform 0.7s ease;
5080
5617
  }
5081
5618
 
5619
+ .lyrics-word.char-drag span.char {
5620
+ transition: color 0.18s;
5621
+ }
5622
+
5082
5623
  /* Active char spans: structural only, wipe animation sets gradient */
5083
5624
  .lyrics-line.active .lyrics-syllable span.char {
5084
5625
  background-clip: text;
5085
5626
  -webkit-background-clip: text;
5086
5627
  background-repeat: no-repeat;
5087
- background-image:
5088
- linear-gradient(
5089
- 90deg,
5090
- #ffffff00 0%,
5091
- var(--lyplus-text-primary, #fff) 50%,
5092
- #0000 100%
5093
- ),
5094
- linear-gradient(
5095
- 90deg,
5096
- var(--lyplus-text-primary, #fff) 100%,
5097
- #0000 100%
5098
- );
5099
- background-size:
5100
- 0.5em 100%,
5101
- 0% 100%;
5102
- background-position:
5103
- -0.5em 0%,
5104
- -0.25em 0%;
5628
+ background-image: linear-gradient(
5629
+ 90deg,
5630
+ var(--lyplus-text-primary, #fff) 0%,
5631
+ var(--lyplus-text-primary, #fff)
5632
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5633
+ #0000 100%
5634
+ );
5635
+ background-size: 0% 100%;
5636
+ background-position: left;
5105
5637
  transition:
5106
5638
  transform 0.7s ease,
5107
5639
  color 0.18s;
5108
5640
  }
5109
5641
 
5110
5642
  .lyrics-line.active .lyrics-syllable span.char.highlight {
5111
- background-image:
5112
- linear-gradient(
5113
- -90deg,
5114
- var(--lyplus-text-primary, #fff) 0%,
5115
- #0000 100%
5116
- ),
5117
- linear-gradient(
5118
- -90deg,
5119
- var(--lyplus-text-primary, #fff) 100%,
5120
- #0000 100%
5121
- );
5122
- background-position:
5123
- calc(100% + 0.5em) 0%,
5124
- calc(100% + 0.25em) 0%;
5125
- }
5126
-
5127
- .lyrics-line.active .lyrics-syllable.pre-highlight span.char {
5128
- background-image:
5129
- linear-gradient(
5130
- 90deg,
5131
- #ffffff00 0%,
5132
- var(--lyplus-text-primary, #fff) 50%,
5133
- #0000 100%
5134
- ),
5135
- linear-gradient(
5136
- 90deg,
5137
- var(--lyplus-text-primary, #fff) 100%,
5138
- #0000 100%
5139
- );
5140
- background-size:
5141
- 0.75em 100%,
5142
- 0% 100%;
5143
- background-position:
5144
- -0.85em 0%,
5145
- -0.25em 0%;
5643
+ background-image: linear-gradient(
5644
+ -90deg,
5645
+ var(--lyplus-text-primary, #fff) 0%,
5646
+ var(--lyplus-text-primary, #fff)
5647
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5648
+ #0000 100%
5649
+ );
5650
+ background-size: 0% 100%;
5651
+ background-position: right 0%;
5652
+ }
5653
+
5654
+ .lyrics-line.active .lyrics-syllable span.char.pre-wipe-lead {
5655
+ animation-name: pre-wipe-word-char;
5656
+ animation-duration: var(--pre-wipe-duration);
5657
+ animation-delay: var(--pre-wipe-delay);
5658
+ animation-timing-function: linear;
5659
+ animation-fill-mode: forwards;
5660
+ background-image: linear-gradient(
5661
+ 90deg,
5662
+ var(--lyplus-text-primary, #fff) 0%,
5663
+ var(--lyplus-text-primary, #fff)
5664
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5665
+ #0000 100%
5666
+ );
5667
+ background-size: 0% 100%;
5668
+ background-position: var(--char-wipe-position, left) 0%;
5146
5669
  }
5147
5670
 
5148
5671
  /* ==========================================================================
@@ -5219,13 +5742,7 @@ AmLyrics.styles = i$3 `
5219
5742
  color: var(--lyplus-text-primary) !important;
5220
5743
  }
5221
5744
 
5222
- .lyrics-line.pre-active .lyrics-syllable.line-synced {
5223
- animation: fade-in-line 0.14s ease-out forwards !important;
5224
- color: var(--lyplus-text-primary) !important;
5225
- }
5226
-
5227
- .lyrics-line.active .lyrics-syllable.line-synced span.char,
5228
- .lyrics-line.pre-active .lyrics-syllable.line-synced span.char {
5745
+ .lyrics-line.active .lyrics-syllable.line-synced span.char {
5229
5746
  background-image: none !important;
5230
5747
  background-color: var(--lyplus-text-primary) !important;
5231
5748
  transition: background-color 120ms ease-out !important;
@@ -5398,6 +5915,49 @@ AmLyrics.styles = i$3 `
5398
5915
  gap: 4px;
5399
5916
  }
5400
5917
 
5918
+ .source-switch-btn {
5919
+ position: relative;
5920
+ display: inline-flex;
5921
+ align-items: center;
5922
+ padding: 2px 8px;
5923
+ border: 1px solid rgba(255, 255, 255, 0.2);
5924
+ min-height: 28px;
5925
+ background: transparent;
5926
+ border-radius: 6px;
5927
+ color: #aaa;
5928
+ cursor: pointer;
5929
+ font-family: inherit;
5930
+ font-size: 11px;
5931
+ transition:
5932
+ color 0.2s ease,
5933
+ border-color 0.2s ease,
5934
+ background-color 0.2s ease,
5935
+ transform 0.12s ease;
5936
+ }
5937
+
5938
+ .source-switch-btn::before {
5939
+ content: '';
5940
+ position: absolute;
5941
+ inset: -6px;
5942
+ }
5943
+
5944
+ .source-switch-btn:active:not(:disabled) {
5945
+ transform: scale(0.96);
5946
+ }
5947
+
5948
+ .source-switch-btn:disabled {
5949
+ cursor: default;
5950
+ opacity: 0.7;
5951
+ }
5952
+
5953
+ .source-switch-svg {
5954
+ margin-right: 4px;
5955
+ }
5956
+
5957
+ .source-switch-svg.is-loading {
5958
+ animation: source-switch-spin 1s linear infinite;
5959
+ }
5960
+
5401
5961
  .control-button {
5402
5962
  background: transparent;
5403
5963
  border: 1px solid rgba(255, 255, 255, 0.3);
@@ -5406,7 +5966,10 @@ AmLyrics.styles = i$3 `
5406
5966
  font-size: 0.8em;
5407
5967
  color: rgba(255, 255, 255, 0.6);
5408
5968
  cursor: pointer;
5409
- transition: all 0.2s;
5969
+ transition:
5970
+ color 0.2s,
5971
+ border-color 0.2s,
5972
+ background-color 0.2s;
5410
5973
  font-weight: normal;
5411
5974
  }
5412
5975
 
@@ -5544,137 +6107,125 @@ AmLyrics.styles = i$3 `
5544
6107
  KEYFRAME ANIMATIONS
5545
6108
  ========================================================================== */
5546
6109
 
6110
+ @keyframes source-switch-spin {
6111
+ to {
6112
+ transform: rotate(360deg);
6113
+ }
6114
+ }
6115
+
5547
6116
  /* Wipe animation for syllables */
5548
6117
  @keyframes wipe {
5549
6118
  from {
5550
- background-size:
5551
- 0.75em 100%,
5552
- 0% 100%;
5553
- background-position:
5554
- -0.375em 0%,
5555
- left;
6119
+ background-size: 0% 100%;
6120
+ background-position: left;
6121
+ }
6122
+ to {
6123
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6124
+ background-position: left;
6125
+ }
6126
+ }
6127
+
6128
+ @keyframes wipe-from-pre {
6129
+ from {
6130
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6131
+ background-position: left;
5556
6132
  }
5557
6133
  to {
5558
- background-size:
5559
- 0.75em 100%,
5560
- 100% 100%;
5561
- background-position:
5562
- calc(100% + 0.375em) 0%,
5563
- left;
6134
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6135
+ background-position: left;
5564
6136
  }
5565
6137
  }
5566
6138
 
5567
6139
  @keyframes start-wipe {
5568
6140
  0% {
5569
- background-size:
5570
- 0.75em 100%,
5571
- 0% 100%;
5572
- background-position:
5573
- -0.75em 0%,
5574
- -0.375em 0%;
6141
+ background-size: 0% 100%;
6142
+ background-position: left;
5575
6143
  }
5576
6144
  100% {
5577
- background-size:
5578
- 0.75em 100%,
5579
- 100% 100%;
5580
- background-position:
5581
- calc(100% + 0.375em) 0%,
5582
- left;
6145
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6146
+ background-position: left;
5583
6147
  }
5584
6148
  }
5585
6149
 
5586
6150
  @keyframes wipe-rtl {
5587
6151
  from {
5588
- background-size:
5589
- 0.75em 100%,
5590
- 0% 100%;
5591
- background-position:
5592
- calc(100% + 0.375em) 0%,
5593
- calc(100% + 0.36em) 0%;
6152
+ background-size: 0% 100%;
6153
+ background-position: right 0%;
5594
6154
  }
5595
6155
  to {
5596
- background-size:
5597
- 0.75em 100%,
5598
- 100% 100%;
5599
- background-position:
5600
- -0.75em 0%,
5601
- right 0%;
6156
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6157
+ background-position: right 0%;
6158
+ }
6159
+ }
6160
+
6161
+ @keyframes wipe-from-pre-rtl {
6162
+ from {
6163
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6164
+ background-position: right 0%;
6165
+ }
6166
+ to {
6167
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6168
+ background-position: right 0%;
5602
6169
  }
5603
6170
  }
5604
6171
 
5605
6172
  @keyframes start-wipe-rtl {
5606
6173
  0% {
5607
- background-size:
5608
- 0.75em 100%,
5609
- 0% 100%;
5610
- background-position:
5611
- calc(100% + 0.75em) 0%,
5612
- calc(100% + 0.5em) 0%;
6174
+ background-size: 0% 100%;
6175
+ background-position: right 0%;
5613
6176
  }
5614
6177
  100% {
5615
- background-size:
5616
- 0.75em 100%,
5617
- 100% 100%;
5618
- background-position:
5619
- -0.75em 0%,
5620
- right 0%;
6178
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6179
+ background-position: right 0%;
5621
6180
  }
5622
6181
  }
5623
6182
 
5624
6183
  @keyframes pre-wipe-universal {
5625
6184
  from {
5626
- background-size:
5627
- 0.75em 100%,
5628
- 0% 100%;
5629
- background-position:
5630
- -0.75em 0%,
5631
- left;
6185
+ background-size: 0% 100%;
6186
+ background-position: left;
5632
6187
  }
5633
6188
  to {
5634
- background-size:
5635
- 0.75em 100%,
5636
- 0% 100%;
5637
- background-position:
5638
- -0.375em 0%,
5639
- left;
6189
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6190
+ background-position: left;
5640
6191
  }
5641
6192
  }
5642
6193
 
5643
6194
  @keyframes pre-wipe-universal-rtl {
5644
6195
  from {
5645
- background-size:
5646
- 0.75em 100%,
5647
- 0% 100%;
5648
- background-position:
5649
- calc(100% + 0.75em) 0%,
5650
- right 0%;
6196
+ background-size: 0% 100%;
6197
+ background-position: right 0%;
6198
+ }
6199
+ to {
6200
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6201
+ background-position: right 0%;
6202
+ }
6203
+ }
6204
+
6205
+ @keyframes pre-wipe-word-char {
6206
+ from {
6207
+ background-size: 0px 100%;
6208
+ background-position: var(--char-wipe-position, left) 0%;
5651
6209
  }
5652
6210
  to {
5653
- background-size:
5654
- 0.75em 100%,
5655
- 0% 100%;
5656
- background-position:
5657
- calc(100% + 0.375em) 0%,
5658
- right 0%;
6211
+ background-size: var(--pre-wipe-word-size, var(--wipe-gradient-width))
6212
+ 100%;
6213
+ background-position: var(--char-wipe-position, left) 0%;
5659
6214
  }
5660
6215
  }
5661
6216
 
5662
- @keyframes pre-wipe-char {
6217
+ @keyframes wipe-word-from-pre {
5663
6218
  from {
5664
- background-size:
5665
- 0.75em 100%,
5666
- 0% 100%;
5667
- background-position:
5668
- -0.75em 0%,
5669
- left;
6219
+ background-size: var(--pre-wipe-word-size, var(--wipe-gradient-width))
6220
+ 100%;
6221
+ background-position: var(--char-wipe-position, left) 0%;
5670
6222
  }
5671
6223
  to {
5672
- background-size:
5673
- 0.75em 100%,
5674
- 0% 100%;
5675
- background-position:
5676
- -0.375em 0%,
5677
- left;
6224
+ background-size: calc(
6225
+ var(--word-wipe-width, 100%) + var(--wipe-gradient-width, 0.75em)
6226
+ )
6227
+ 100%;
6228
+ background-position: var(--char-wipe-position, left) 0%;
5678
6229
  }
5679
6230
  }
5680
6231
 
@@ -5768,6 +6319,15 @@ AmLyrics.styles = i$3 `
5768
6319
  }
5769
6320
  }
5770
6321
 
6322
+ @keyframes drag-char {
6323
+ 0% {
6324
+ transform: translate3d(0, 0, 0);
6325
+ }
6326
+ 100% {
6327
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
6328
+ }
6329
+ }
6330
+
5771
6331
  @keyframes grow-static {
5772
6332
  0%,
5773
6333
  100% {