@uimaxbai/am-lyrics 1.5.4 → 1.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -319,7 +319,7 @@ class GoogleService {
319
319
  }
320
320
  }
321
321
 
322
- const VERSION = '1.5.4';
322
+ const VERSION = '1.5.6';
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 = 100;
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,11 +496,12 @@ 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
- const activeLines = this.lyricsContainer.querySelectorAll('.lyrics-line.active, .lyrics-line.pre-active, .lyrics-line.bg-expanded');
502
+ const activeLines = this.lyricsContainer.querySelectorAll('.lyrics-line.active, .lyrics-line.pre-active, .lyrics-line.bg-expanded, .lyrics-line.scroll-exiting');
492
503
  activeLines.forEach(line => {
493
- line.classList.remove('active', 'pre-active', 'bg-expanded');
504
+ line.classList.remove('active', 'pre-active', 'bg-expanded', 'scroll-exiting');
494
505
  AmLyrics.resetSyllables(line);
495
506
  });
496
507
  const activeGaps = this.lyricsContainer.querySelectorAll('.lyrics-gap.active, .lyrics-gap.gap-exiting');
@@ -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,13 +1945,15 @@ 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 {
1902
1954
  AmLyrics.finishSyllablesUpToTime(lineElement, newTime);
1903
1955
  }
1904
- lineElement.classList.remove('active', 'bg-expanded');
1956
+ lineElement.classList.remove('active', 'bg-expanded', 'scroll-exiting');
1905
1957
  if (lineElement.classList.contains('pre-active')) {
1906
1958
  lineElement.classList.remove('pre-active');
1907
1959
  }
@@ -1911,13 +1963,14 @@ 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');
1920
- lineElement.classList.remove('pre-active');
1972
+ lineElement.classList.add('active');
1973
+ lineElement.classList.remove('pre-active', 'scroll-exiting');
1921
1974
  const preIdx = this.preActiveLineElements.indexOf(lineElement);
1922
1975
  if (preIdx !== -1)
1923
1976
  this.preActiveLineElements.splice(preIdx, 1);
@@ -2122,7 +2175,11 @@ class AmLyrics extends i {
2122
2175
  }
2123
2176
  }
2124
2177
  updated(changedProperties) {
2125
- if (changedProperties.has('lyrics')) {
2178
+ const lyricsDomBecameRenderable = changedProperties.has('lyrics') ||
2179
+ (changedProperties.has('isLoading') &&
2180
+ !this.isLoading &&
2181
+ Boolean(this.lyrics));
2182
+ if (lyricsDomBecameRenderable) {
2126
2183
  this._invalidateCaches();
2127
2184
  this._ensureLineDataCache();
2128
2185
  this._updateCachedIsUnsynced();
@@ -2136,8 +2193,12 @@ class AmLyrics extends i {
2136
2193
  for (const lineIndex of activeLines) {
2137
2194
  const lineEl = this._getLineElement(lineIndex);
2138
2195
  if (lineEl)
2139
- lineEl.classList.add('active', 'bg-expanded');
2196
+ lineEl.classList.add('active');
2140
2197
  }
2198
+ const primaryActiveLine = this.getPrimaryActiveLineIndex(activeLines);
2199
+ this.setBackgroundExpandedLine(primaryActiveLine !== null
2200
+ ? this._getLineElement(primaryActiveLine)
2201
+ : null);
2141
2202
  // Trigger a faux time-change so that updateSyllablesForLine fires
2142
2203
  // to setup inline syllable CSS wipe animations for whatever the current time is
2143
2204
  this._onTimeChanged(0, this.currentTime);
@@ -2176,6 +2237,7 @@ class AmLyrics extends i {
2176
2237
  this.preActiveLineElements = [];
2177
2238
  this.positionedLineElements = [];
2178
2239
  this.activeGapLineElements = [];
2240
+ this.clearBackgroundExpandedLine();
2179
2241
  this.setUserScrolling(false);
2180
2242
  // Cancel any running animations
2181
2243
  if (this.animationFrameId) {
@@ -2230,6 +2292,7 @@ class AmLyrics extends i {
2230
2292
  // Don't override it with a scroll back to the last lyric.
2231
2293
  const footer = this.lyricsContainer.querySelector('.lyrics-footer');
2232
2294
  if (footer?.classList.contains('active')) {
2295
+ this.setBackgroundExpandedLine(null);
2233
2296
  return;
2234
2297
  }
2235
2298
  // 1. Compute scroll lookahead based on gap to next line (YouLyPlus style)
@@ -2270,17 +2333,23 @@ class AmLyrics extends i {
2270
2333
  targetElement = this._getLineElement(targetLineIdx);
2271
2334
  }
2272
2335
  }
2273
- if (!targetElement)
2336
+ if (!targetElement) {
2337
+ this.setBackgroundExpandedLine(null);
2274
2338
  return;
2275
- // Unblur the upcoming target line early (pre-active) so background
2276
- // vocals start their max-height/opacity transition in sync with scroll.
2339
+ }
2340
+ const scrollDuration = scrollLookAheadMs;
2341
+ if (targetElement !== this.currentPrimaryActiveLine || forceScroll) {
2342
+ targetElement.style.setProperty('--scroll-duration', `${scrollDuration}ms`);
2343
+ }
2344
+ this.setBackgroundExpandedLine(targetElement);
2345
+ // Unblur the upcoming target line early while the separate bg-expanded
2346
+ // class starts background vocal height/opacity in sync with scroll.
2277
2347
  if (!targetElement.classList.contains('active')) {
2278
2348
  targetElement.classList.add('pre-active');
2279
2349
  if (!this.preActiveLineElements.includes(targetElement)) {
2280
2350
  this.preActiveLineElements.push(targetElement);
2281
2351
  }
2282
2352
  }
2283
- const scrollDuration = scrollLookAheadMs;
2284
2353
  this.focusLine(targetElement, forceScroll, scrollDuration);
2285
2354
  }
2286
2355
  _getTextWidth(text, font) {
@@ -2353,6 +2422,7 @@ class AmLyrics extends i {
2353
2422
  this.preActiveLineElements = [];
2354
2423
  this.positionedLineElements = [];
2355
2424
  this.activeGapLineElements = [];
2425
+ this.clearBackgroundExpandedLine();
2356
2426
  this.visibilityObserver?.disconnect();
2357
2427
  this.visibilityObserver = undefined;
2358
2428
  }
@@ -2388,6 +2458,7 @@ class AmLyrics extends i {
2388
2458
  const groupGrowable = new Array(wordGroups.length).fill(false);
2389
2459
  const groupGlowing = new Array(wordGroups.length).fill(false);
2390
2460
  const groupCharRise = new Array(wordGroups.length).fill(false);
2461
+ const groupCharDrag = new Array(wordGroups.length).fill(false);
2391
2462
  const vwFullText = new Array(wordGroups.length).fill('');
2392
2463
  const vwFullDuration = new Array(wordGroups.length).fill(0);
2393
2464
  const vwCharOffset = new Array(wordGroups.length).fill(0);
@@ -2423,28 +2494,42 @@ class AmLyrics extends i {
2423
2494
  const isLineSynced = line.isWordSynced === false || line.text.some(s => s.lineSynced);
2424
2495
  let isGrowableVW = canAnimateByChar && wordLen > 0 && wordLen <= 7;
2425
2496
  if (isGrowableVW) {
2426
- if (wordLen < 3) {
2497
+ if (wordLen <= 1) {
2427
2498
  isGrowableVW =
2428
2499
  combinedDuration >= 1050 && combinedDuration >= wordLen * 525;
2429
2500
  }
2501
+ else if (wordLen <= 3) {
2502
+ isGrowableVW =
2503
+ combinedDuration >=
2504
+ SHORT_WORD_GLOW_MIN_DURATION_MS + (wordLen - 2) * 140;
2505
+ }
2430
2506
  else {
2431
2507
  isGrowableVW =
2432
2508
  combinedDuration >= 850 && combinedDuration >= wordLen * 190;
2433
2509
  }
2434
2510
  }
2435
2511
  const hasCharRiseDuration = combinedDuration >= Math.max(700, wordLen * 85);
2512
+ const hasTinyWordDragDuration = wordLen >= 2 &&
2513
+ wordLen <= 3 &&
2514
+ combinedDuration >=
2515
+ Math.max(SHORT_WORD_DRAG_MIN_DURATION_MS, wordLen * 150);
2436
2516
  const hasLongShortWordDuration = wordLen >= 4 && combinedDuration >= Math.max(1300, wordLen * 260);
2437
2517
  const isCharRiseVW = canAnimateByChar &&
2438
2518
  !isLineSynced &&
2439
2519
  !isGrowableVW &&
2440
2520
  ((wordLen >= 8 && hasCharRiseDuration) ||
2441
2521
  (wordLen < 8 && hasLongShortWordDuration));
2522
+ const isCharDragVW = canAnimateByChar &&
2523
+ !isLineSynced &&
2524
+ !isGrowableVW &&
2525
+ hasTinyWordDragDuration;
2442
2526
  const isGlowingVW = isGrowableVW && !isLineSynced;
2443
2527
  let charOff = 0;
2444
2528
  for (let gi = vwStart; gi <= vwEnd; gi += 1) {
2445
2529
  groupGrowable[gi] = isGrowableVW;
2446
2530
  groupGlowing[gi] = isGlowingVW;
2447
2531
  groupCharRise[gi] = isCharRiseVW;
2532
+ groupCharDrag[gi] = isCharDragVW;
2448
2533
  vwFullText[gi] = combinedText;
2449
2534
  vwFullDuration[gi] = combinedDuration;
2450
2535
  vwCharOffset[gi] = charOff;
@@ -2460,6 +2545,7 @@ class AmLyrics extends i {
2460
2545
  groupGrowable,
2461
2546
  groupGlowing,
2462
2547
  groupCharRise,
2548
+ groupCharDrag,
2463
2549
  vwFullText,
2464
2550
  vwFullDuration,
2465
2551
  vwCharOffset,
@@ -2479,48 +2565,106 @@ class AmLyrics extends i {
2479
2565
  return;
2480
2566
  const computedStyle = getComputedStyle(referenceSyllable);
2481
2567
  const { font } = computedStyle; // Full font string
2482
- const fontSize = parseFloat(computedStyle.fontSize);
2483
- const charTimedWords = this.shadowRoot.querySelectorAll('.lyrics-word.growable, .lyrics-word.char-rise');
2484
- if (!charTimedWords)
2568
+ const fontSize = Number.parseFloat(computedStyle.fontSize) || 16;
2569
+ const charTimedWords = Array.from(this.shadowRoot.querySelectorAll('.lyrics-word.growable, .lyrics-word.char-rise, .lyrics-word.char-drag'));
2570
+ if (charTimedWords.length === 0)
2485
2571
  return;
2486
- charTimedWords.forEach((wordSpan) => {
2487
- const syllableWraps = wordSpan.querySelectorAll('.lyrics-syllable-wrap');
2488
- // Flatten syllables
2572
+ const wordsByVirtualId = new Map();
2573
+ charTimedWords.forEach((wordSpan, index) => {
2574
+ const virtualWordId = wordSpan.dataset.virtualWordId || `word-${index}`;
2575
+ const words = wordsByVirtualId.get(virtualWordId);
2576
+ if (words) {
2577
+ words.push(wordSpan);
2578
+ }
2579
+ else {
2580
+ wordsByVirtualId.set(virtualWordId, [wordSpan]);
2581
+ }
2582
+ });
2583
+ wordsByVirtualId.forEach(wordSpans => {
2489
2584
  const syllables = [];
2490
- syllableWraps.forEach((wrap) => {
2491
- const syl = wrap.querySelector('.lyrics-syllable');
2492
- if (syl)
2493
- syllables.push(syl);
2585
+ wordSpans.forEach(wordSpan => {
2586
+ const syllableWraps = wordSpan.querySelectorAll('.lyrics-syllable-wrap');
2587
+ syllableWraps.forEach(wrap => {
2588
+ const syl = wrap.querySelector('.lyrics-syllable');
2589
+ if (syl)
2590
+ syllables.push(syl);
2591
+ });
2494
2592
  });
2495
- syllables.forEach(sylSpan => {
2496
- const charSpans = sylSpan.querySelectorAll('.char');
2497
- if (charSpans.length === 0)
2498
- return;
2499
- // Logic from YouLyPlus renderCharWipes:
2500
- // Use textContent from spans to ensure we measure what is rendered
2501
- const chars = Array.from(charSpans).map(span => span.textContent || '');
2502
- const charWidths = chars.map(c => this._getTextWidth(c, font));
2503
- const totalSyllableWidth = charWidths.reduce((a, b) => a + b, 0);
2504
- const duration = parseFloat(sylSpan.dataset.duration || '0');
2505
- const velocityPxPerMs = duration > 0 ? totalSyllableWidth / duration : 0;
2506
- // Gradient width in pixels = 0.375 * fontSize
2507
- // This matches YouLyPlus visual gradient size
2508
- const gradientWidthPx = 0.375 * fontSize;
2509
- const gradientDurationMs = velocityPxPerMs > 0 ? gradientWidthPx / velocityPxPerMs : 100;
2510
- let cumulativeCharWidth = 0;
2511
- charSpans.forEach((spanArg, i) => {
2512
- const charWidth = charWidths[i];
2513
- const span = spanArg;
2514
- if (totalSyllableWidth > 0) {
2515
- const startPercent = cumulativeCharWidth / totalSyllableWidth;
2516
- const durationPercent = charWidth / totalSyllableWidth;
2517
- span.dataset.wipeStart = startPercent.toFixed(4);
2518
- span.dataset.wipeDuration = durationPercent.toFixed(4);
2519
- // The critical missing piece:
2520
- span.dataset.preWipeArrival = (duration * startPercent).toFixed(2);
2521
- span.dataset.preWipeDuration = gradientDurationMs.toFixed(2);
2593
+ const charSpans = syllables.flatMap(syl => {
2594
+ const spans = Array.from(syl.querySelectorAll('.char'));
2595
+ const target = syl;
2596
+ target._cachedCharSpans = spans;
2597
+ return spans;
2598
+ });
2599
+ if (charSpans.length === 0)
2600
+ return;
2601
+ wordSpans.forEach(wordSpan => {
2602
+ const target = wordSpan;
2603
+ target._cachedVirtualWordElements = wordSpans;
2604
+ target._cachedVirtualWordCharSpans = charSpans;
2605
+ });
2606
+ const syllableEntries = syllables.map(syl => {
2607
+ const spans = syl._cachedCharSpans;
2608
+ const charWidths = spans.map(span => this._getTextWidth(span.textContent || '', font));
2609
+ const totalWidth = charWidths.reduce((a, b) => a + b, 0);
2610
+ return {
2611
+ syl,
2612
+ spans,
2613
+ charWidths,
2614
+ totalWidth,
2615
+ start: parseFloat(syl.getAttribute('data-start-time') || ''),
2616
+ end: parseFloat(syl.getAttribute('data-end-time') || ''),
2617
+ };
2618
+ });
2619
+ const totalWordWidth = syllableEntries.reduce((total, entry) => total + entry.totalWidth, 0);
2620
+ if (totalWordWidth <= 0)
2621
+ return;
2622
+ const virtualWordStart = Math.min(...syllableEntries
2623
+ .map(entry => entry.start)
2624
+ .filter(start => Number.isFinite(start)));
2625
+ const virtualWordEnd = Math.max(...syllableEntries
2626
+ .map(entry => entry.end)
2627
+ .filter(end => Number.isFinite(end)));
2628
+ const virtualWordDuration = virtualWordEnd - virtualWordStart;
2629
+ const hasTimedSyllables = Number.isFinite(virtualWordStart) &&
2630
+ Number.isFinite(virtualWordEnd) &&
2631
+ virtualWordDuration > 0;
2632
+ const wordVelocityPxPerMs = hasTimedSyllables
2633
+ ? totalWordWidth / virtualWordDuration
2634
+ : 0;
2635
+ const gradientLeadWidthPx = (BASE_WIPE_GRADIENT_EM * Math.max(1, fontSize)) / 2;
2636
+ const measuredPreWipeDuration = wordVelocityPxPerMs > 0
2637
+ ? gradientLeadWidthPx / wordVelocityPxPerMs
2638
+ : 100;
2639
+ let cumulativeCharWidth = 0;
2640
+ syllableEntries.forEach(entry => {
2641
+ let cumulativeSyllableWidth = 0;
2642
+ const syllableDuration = entry.end - entry.start;
2643
+ const useSyllableTiming = hasTimedSyllables &&
2644
+ Number.isFinite(entry.start) &&
2645
+ Number.isFinite(entry.end) &&
2646
+ syllableDuration > 0 &&
2647
+ entry.totalWidth > 0;
2648
+ entry.spans.forEach((span, index) => {
2649
+ const charWidth = entry.charWidths[index];
2650
+ let startPercent = cumulativeCharWidth / totalWordWidth;
2651
+ let durationPercent = charWidth / totalWordWidth;
2652
+ if (useSyllableTiming) {
2653
+ const charStartMs = entry.start -
2654
+ virtualWordStart +
2655
+ (cumulativeSyllableWidth / entry.totalWidth) * syllableDuration;
2656
+ const charDurationMs = (charWidth / entry.totalWidth) * syllableDuration;
2657
+ startPercent = AmLyrics.clamp(charStartMs / virtualWordDuration, 0, 1);
2658
+ durationPercent = AmLyrics.clamp(charDurationMs / virtualWordDuration, 0, 1);
2522
2659
  }
2660
+ const target = span;
2661
+ target.dataset.wipeStart = startPercent.toFixed(4);
2662
+ target.dataset.wipeDuration = durationPercent.toFixed(4);
2663
+ target.dataset.preWipeDuration = measuredPreWipeDuration.toFixed(2);
2664
+ target.style.setProperty('--word-wipe-width', `${totalWordWidth}px`);
2665
+ target.style.setProperty('--char-wipe-position', `${-cumulativeCharWidth}px`);
2523
2666
  cumulativeCharWidth += charWidth;
2667
+ cumulativeSyllableWidth += charWidth;
2524
2668
  });
2525
2669
  });
2526
2670
  });
@@ -2528,6 +2672,33 @@ class AmLyrics extends i {
2528
2672
  static arraysEqual(a, b) {
2529
2673
  return a.length === b.length && a.every((val, i) => val === b[i]);
2530
2674
  }
2675
+ static isLineSyncedLine(line) {
2676
+ if (!line)
2677
+ return false;
2678
+ return line.isWordSynced === false || line.text.some(s => s.lineSynced);
2679
+ }
2680
+ getLineHighlightEndTime(index) {
2681
+ if (!this.lyrics)
2682
+ return 0;
2683
+ const line = this.lyrics[index];
2684
+ if (!line)
2685
+ return 0;
2686
+ const rawEnd = Math.max(line.endtime, line.timestamp);
2687
+ const nextLine = this.lyrics[index + 1];
2688
+ if (!nextLine || nextLine.timestamp <= line.timestamp) {
2689
+ return rawEnd > line.timestamp ? rawEnd + 200 : rawEnd;
2690
+ }
2691
+ if (rawEnd > line.timestamp) {
2692
+ if (nextLine.timestamp < rawEnd) {
2693
+ return rawEnd;
2694
+ }
2695
+ const gapToNext = nextLine.timestamp - rawEnd;
2696
+ if (gapToNext >= INSTRUMENTAL_THRESHOLD_MS) {
2697
+ return rawEnd;
2698
+ }
2699
+ }
2700
+ return nextLine.timestamp;
2701
+ }
2531
2702
  static getLineIndexFromElement(lineElement) {
2532
2703
  if (!lineElement)
2533
2704
  return null;
@@ -2558,6 +2729,26 @@ class AmLyrics extends i {
2558
2729
  }
2559
2730
  this.preActiveLineElements = keptLines;
2560
2731
  }
2732
+ setBackgroundExpandedLine(lineElement) {
2733
+ const target = lineElement &&
2734
+ !lineElement.classList.contains('lyrics-gap') &&
2735
+ lineElement.querySelector('.background-vocal-container')
2736
+ ? lineElement
2737
+ : null;
2738
+ if (this.backgroundExpandedLine === target) {
2739
+ if (target && !target.classList.contains('bg-expanded')) {
2740
+ target.classList.add('bg-expanded');
2741
+ }
2742
+ return;
2743
+ }
2744
+ this.backgroundExpandedLine?.classList.remove('bg-expanded');
2745
+ this.backgroundExpandedLine = target;
2746
+ target?.classList.add('bg-expanded');
2747
+ }
2748
+ clearBackgroundExpandedLine() {
2749
+ this.backgroundExpandedLine?.classList.remove('bg-expanded');
2750
+ this.backgroundExpandedLine = null;
2751
+ }
2561
2752
  getPrimaryActiveLineIndex(activeIndices) {
2562
2753
  if (activeIndices.length === 0)
2563
2754
  return null;
@@ -2634,7 +2825,12 @@ class AmLyrics extends i {
2634
2825
  // effectiveEndTimes). Lines stay active until their extended end,
2635
2826
  // so we no longer need to remove .active here.
2636
2827
  this.lastPrimaryActiveLine = this.currentPrimaryActiveLine;
2828
+ if (this.lastPrimaryActiveLine) {
2829
+ this.lastPrimaryActiveLine.style.setProperty('--scroll-duration', `${scrollDuration ?? SCROLL_ANIMATION_DURATION_MS}ms`);
2830
+ this.lastPrimaryActiveLine.classList.add('scroll-exiting');
2831
+ }
2637
2832
  this.currentPrimaryActiveLine = lineElement;
2833
+ this.currentPrimaryActiveLine.classList.remove('scroll-exiting');
2638
2834
  const lineIndex = AmLyrics.getLineIndexFromElement(lineElement);
2639
2835
  if (lineIndex !== null) {
2640
2836
  this.lastActiveIndex = lineIndex;
@@ -2747,9 +2943,10 @@ class AmLyrics extends i {
2747
2943
  const activeLines = [];
2748
2944
  for (let i = 0; i < this.lyrics.length; i += 1) {
2749
2945
  const line = this.lyrics[i];
2946
+ const highlightEndTime = this.getLineHighlightEndTime(i);
2750
2947
  if (line.timestamp > time)
2751
2948
  break;
2752
- if (time >= line.timestamp && time < line.endtime) {
2949
+ if (time >= line.timestamp && time < highlightEndTime) {
2753
2950
  activeLines.push(i);
2754
2951
  }
2755
2952
  }
@@ -2946,7 +3143,7 @@ class AmLyrics extends i {
2946
3143
  allLines.forEach(lineEl => {
2947
3144
  AmLyrics.resetSyllables(lineEl);
2948
3145
  // Remove scroll-animate class and properties to stop any scroll animations
2949
- lineEl.classList.remove('scroll-animate');
3146
+ lineEl.classList.remove('scroll-animate', 'scroll-exiting');
2950
3147
  lineEl.style.removeProperty('--scroll-delta');
2951
3148
  lineEl.style.removeProperty('--lyrics-line-delay');
2952
3149
  });
@@ -2974,6 +3171,7 @@ class AmLyrics extends i {
2974
3171
  this.lastPrimaryActiveLine = null;
2975
3172
  this.activeLineIds.clear();
2976
3173
  this.animatingLines = [];
3174
+ this.setBackgroundExpandedLine(null);
2977
3175
  // Find the clicked line element and scroll to it with forceScroll (like YouLyPlus)
2978
3176
  // Timestamps are already in milliseconds — match the data-start-time attribute directly
2979
3177
  const clickedLineElement = this.lyricsContainer?.querySelector(`.lyrics-line[data-start-time="${line.text[0]?.timestamp || 0}"]`);
@@ -2990,6 +3188,7 @@ class AmLyrics extends i {
2990
3188
  this.isClickSeeking = false;
2991
3189
  }, 800);
2992
3190
  this.scrollToActiveLineYouLy(clickedLineElement, true);
3191
+ this.setBackgroundExpandedLine(clickedLineElement);
2993
3192
  }
2994
3193
  const event = new CustomEvent('line-click', {
2995
3194
  detail: {
@@ -3167,26 +3366,30 @@ class AmLyrics extends i {
3167
3366
  return;
3168
3367
  const duration = Math.min(450, scrollDuration ?? SCROLL_ANIMATION_DURATION_MS);
3169
3368
  const delayIncrement = duration * 0.1;
3369
+ const maxStaggerSteps = 4;
3170
3370
  const lookAhead = 20;
3171
3371
  const len = lineArray.length;
3172
3372
  const start = Math.max(0, referenceIndex - lookAhead);
3173
3373
  const end = Math.min(len, referenceIndex + lookAhead);
3174
3374
  let maxAnimationDuration = 0;
3175
3375
  const newAnimatingLines = [];
3376
+ const lineDelays = new Map();
3176
3377
  const scrollingDown = delta >= 0;
3177
3378
  if (scrollingDown) {
3178
3379
  let delayCounter = 0;
3179
3380
  for (let i = start; i < end; i += 1) {
3180
3381
  const line = lineArray[i];
3181
- const delay = i >= referenceIndex ? delayCounter * delayIncrement : 0;
3382
+ const delay = i >= referenceIndex
3383
+ ? Math.min(delayCounter, maxStaggerSteps) * delayIncrement
3384
+ : 0;
3182
3385
  if (i >= referenceIndex && !line.classList.contains('lyrics-gap')) {
3183
3386
  delayCounter += 1;
3184
3387
  }
3185
3388
  line.style.setProperty('--scroll-delta', `${delta}px`);
3186
3389
  line.style.setProperty('--lyrics-line-delay', `${delay}ms`);
3187
- line.style.setProperty('--scroll-duration', `${duration + 100}ms`);
3390
+ lineDelays.set(line, delay);
3188
3391
  newAnimatingLines.push(line);
3189
- const lineDuration = duration + delay;
3392
+ const lineDuration = duration + 100 + delay;
3190
3393
  if (lineDuration > maxAnimationDuration) {
3191
3394
  maxAnimationDuration = lineDuration;
3192
3395
  }
@@ -3196,20 +3399,28 @@ class AmLyrics extends i {
3196
3399
  let delayCounter = 0;
3197
3400
  for (let i = end - 1; i >= start; i -= 1) {
3198
3401
  const line = lineArray[i];
3199
- const delay = i <= referenceIndex ? delayCounter * delayIncrement : 0;
3402
+ const delay = i <= referenceIndex
3403
+ ? Math.min(delayCounter, maxStaggerSteps) * delayIncrement
3404
+ : 0;
3200
3405
  if (i <= referenceIndex && !line.classList.contains('lyrics-gap')) {
3201
3406
  delayCounter += 1;
3202
3407
  }
3203
3408
  line.style.setProperty('--scroll-delta', `${delta}px`);
3204
3409
  line.style.setProperty('--lyrics-line-delay', `${delay}ms`);
3205
- line.style.setProperty('--scroll-duration', `${duration + 100}ms`);
3410
+ lineDelays.set(line, delay);
3206
3411
  newAnimatingLines.push(line);
3207
- const lineDuration = duration + delay;
3412
+ const lineDuration = duration + 100 + delay;
3208
3413
  if (lineDuration > maxAnimationDuration) {
3209
3414
  maxAnimationDuration = lineDuration;
3210
3415
  }
3211
3416
  }
3212
3417
  }
3418
+ /* Preserve the staggered starts, but make every line settle together.
3419
+ This keeps the selected line from drifting after its neighbours. */
3420
+ for (const line of newAnimatingLines) {
3421
+ const delay = lineDelays.get(line) ?? 0;
3422
+ line.style.setProperty('--scroll-duration', `${Math.max(100, maxAnimationDuration - delay)}ms`);
3423
+ }
3213
3424
  // --- Step 3: Force reflow so the browser sees the class removal ---
3214
3425
  // Use offsetHeight which is cheaper than getBoundingClientRect
3215
3426
  // eslint-disable-next-line no-void
@@ -3335,30 +3546,255 @@ class AmLyrics extends i {
3335
3546
  }
3336
3547
  /**
3337
3548
  * Update syllable highlight animation - apply CSS wipe animation
3338
- * (Exact copy from YouLyPlus _updateSyllableAnimation)
3339
3549
  */
3550
+ static clamp(value, min, max) {
3551
+ return Math.min(max, Math.max(min, value));
3552
+ }
3553
+ static getVisibleCharacterCount(element) {
3554
+ const attrLength = parseFloat(element.getAttribute('data-word-length') || '');
3555
+ if (Number.isFinite(attrLength) && attrLength > 0)
3556
+ return attrLength;
3557
+ return (element.textContent || '').replace(/\s/g, '').length;
3558
+ }
3559
+ static getLongWordWipeScale(charCount) {
3560
+ if (charCount <= 6)
3561
+ return 1;
3562
+ return (1 +
3563
+ AmLyrics.clamp((charCount - 6) / 10, 0, 1) * LONG_WORD_WIPE_EXTRA_RATIO);
3564
+ }
3565
+ static applyWipeShape(element, charCount) {
3566
+ const extra = AmLyrics.clamp((charCount - 6) / 10, 0, 1) * LONG_WORD_WIPE_EXTRA_EM;
3567
+ const width = BASE_WIPE_GRADIENT_EM + extra;
3568
+ element.style.setProperty('--wipe-gradient-width', `${width.toFixed(3)}em`);
3569
+ element.style.setProperty('--wipe-gradient-half', `${(width / 2).toFixed(3)}em`);
3570
+ }
3571
+ static ensureWordWipeGeometry(charSpans, charCount) {
3572
+ if (charSpans.length === 0)
3573
+ return;
3574
+ const approxWidthCh = Math.max(1, charCount || charSpans.length);
3575
+ charSpans.forEach((span, index) => {
3576
+ if (!span.style.getPropertyValue('--word-wipe-width')) {
3577
+ span.style.setProperty('--word-wipe-width', `${approxWidthCh}ch`);
3578
+ }
3579
+ if (!span.style.getPropertyValue('--char-wipe-position')) {
3580
+ const startPct = Number.parseFloat(span.dataset.wipeStart || `${index / Math.max(1, charSpans.length)}`);
3581
+ span.style.setProperty('--char-wipe-position', `${-(AmLyrics.clamp(startPct, 0, 1) * approxWidthCh)}ch`);
3582
+ }
3583
+ });
3584
+ }
3585
+ static clearPreHighlight(syllable) {
3586
+ const target = syllable;
3587
+ target.classList.remove('pre-highlight');
3588
+ target.style.removeProperty('--pre-wipe-duration');
3589
+ target.style.removeProperty('--pre-wipe-delay');
3590
+ target.style.animation = '';
3591
+ target
3592
+ .querySelectorAll('.pre-wipe-lead')
3593
+ .forEach(element => AmLyrics.clearPreWipeLead(element));
3594
+ }
3595
+ static clearPreWipeLead(element) {
3596
+ element.classList.remove('pre-wipe-lead');
3597
+ element.style.removeProperty('--pre-wipe-duration');
3598
+ element.style.removeProperty('--pre-wipe-delay');
3599
+ }
3600
+ static hasTextBoundaryAfter(syllable) {
3601
+ return /\s$/.test(syllable.textContent || '');
3602
+ }
3603
+ static getSyllableWordIndex(syllable) {
3604
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3605
+ const virtualWordId = wordElement?.dataset.virtualWordId;
3606
+ if (virtualWordId) {
3607
+ return `virtual:${virtualWordId}`;
3608
+ }
3609
+ const virtualWordStart = wordElement?.dataset.virtualWordStart;
3610
+ const virtualWordEnd = wordElement?.dataset.virtualWordEnd;
3611
+ if (virtualWordStart || virtualWordEnd) {
3612
+ return `virtual:${virtualWordStart || ''}:${virtualWordEnd || ''}`;
3613
+ }
3614
+ return (syllable.getAttribute('data-word-index') ||
3615
+ syllable.getAttribute('data-syllable-index') ||
3616
+ '');
3617
+ }
3618
+ static getNextWordSyllable(syllables, index) {
3619
+ const current = syllables[index];
3620
+ const currentWordIndex = AmLyrics.getSyllableWordIndex(current);
3621
+ const previousSyllable = current;
3622
+ for (let i = index + 1; i < syllables.length; i += 1) {
3623
+ const candidate = syllables[i];
3624
+ if (candidate.classList.contains('transliteration')) {
3625
+ // eslint-disable-next-line no-continue
3626
+ continue;
3627
+ }
3628
+ const candidateWordIndex = AmLyrics.getSyllableWordIndex(candidate);
3629
+ if (candidateWordIndex === currentWordIndex ||
3630
+ !AmLyrics.hasTextBoundaryAfter(previousSyllable)) {
3631
+ return null;
3632
+ }
3633
+ return candidate;
3634
+ }
3635
+ return null;
3636
+ }
3637
+ static getPreviousNonTransliterationSyllable(syllables, index) {
3638
+ for (let i = index - 1; i >= 0; i -= 1) {
3639
+ const candidate = syllables[i];
3640
+ if (!candidate.classList.contains('transliteration')) {
3641
+ return candidate;
3642
+ }
3643
+ }
3644
+ return null;
3645
+ }
3646
+ static getRenderedWordSyllables(syllable) {
3647
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3648
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
3649
+ const wordSyllables = wordElements.flatMap(element => Array.from(element.querySelectorAll('.lyrics-syllable')));
3650
+ return wordSyllables.filter(wordSyllable => !wordSyllable.classList.contains('transliteration'));
3651
+ }
3652
+ static getWordElementForSyllable(syllable) {
3653
+ return syllable.parentElement?.parentElement;
3654
+ }
3655
+ static getWordPreWipeKey(syllable) {
3656
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3657
+ return (wordElement?.dataset.virtualWordId ||
3658
+ `${syllable.getAttribute('data-start-time') || ''}:${AmLyrics.getSyllableWordIndex(syllable)}`);
3659
+ }
3660
+ static isPreWipeArmed(syllable) {
3661
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3662
+ const target = wordElement;
3663
+ return Boolean(target?._wordPreWipeKey === AmLyrics.getWordPreWipeKey(syllable));
3664
+ }
3665
+ static applyWordPreWipe(nextSyllable, wordSyllables, currentTimeMs, preWipeStartMs, preWipeDurationMs) {
3666
+ if (AmLyrics.isPreWipeArmed(nextSyllable))
3667
+ return;
3668
+ const wordElement = AmLyrics.getWordElementForSyllable(nextSyllable);
3669
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
3670
+ const charSpans = AmLyrics.getCachedVirtualWordCharSpans(wordElement, []);
3671
+ const elapsedPreWipe = currentTimeMs - preWipeStartMs;
3672
+ const charCount = charSpans.length ||
3673
+ wordSyllables.reduce((total, wordSyllable) => total + AmLyrics.getVisibleCharacterCount(wordSyllable), 0) ||
3674
+ AmLyrics.getVisibleCharacterCount(nextSyllable);
3675
+ AmLyrics.ensureWordWipeGeometry(charSpans, charCount);
3676
+ const leadChar = charSpans[0];
3677
+ const leadCharSyllable = leadChar?.closest('.lyrics-syllable');
3678
+ const preWipeSyllable = leadCharSyllable || wordSyllables[0] || nextSyllable;
3679
+ AmLyrics.applyWipeShape(preWipeSyllable, charCount);
3680
+ preWipeSyllable.style.setProperty('--pre-wipe-duration', `${preWipeDurationMs}ms`);
3681
+ preWipeSyllable.style.setProperty('--pre-wipe-delay', `${-elapsedPreWipe}ms`);
3682
+ preWipeSyllable.classList.add('pre-highlight');
3683
+ // Character-animated words still need a single word-leading gradient.
3684
+ // Giving every glyph this state creates one gradient per character and
3685
+ // makes the whole word enter the pre-wipe continuation simultaneously.
3686
+ if (leadChar) {
3687
+ AmLyrics.applyWipeShape(leadChar, charCount);
3688
+ leadChar.style.setProperty('--pre-wipe-duration', `${preWipeDurationMs}ms`);
3689
+ leadChar.style.setProperty('--pre-wipe-delay', `${-elapsedPreWipe}ms`);
3690
+ leadChar.classList.add('pre-wipe-lead');
3691
+ }
3692
+ wordElements.forEach(element => {
3693
+ const target = element;
3694
+ target._wordPreWipeKey = AmLyrics.getWordPreWipeKey(nextSyllable);
3695
+ });
3696
+ }
3697
+ static maybePreWipeNextWord(syllables, index, currentTimeMs, currentEndTimeMs) {
3698
+ const syllable = syllables[index];
3699
+ if (syllable.classList.contains('line-synced') ||
3700
+ syllable.classList.contains('transliteration') ||
3701
+ syllable.closest('.lyrics-gap')) {
3702
+ return;
3703
+ }
3704
+ const currentWordReady = syllable.classList.contains('finished') ||
3705
+ currentTimeMs >= currentEndTimeMs - WORD_PRE_WIPE_HANDOFF_LEAD_MS;
3706
+ if (!currentWordReady) {
3707
+ return;
3708
+ }
3709
+ const nextSyllable = AmLyrics.getNextWordSyllable(syllables, index);
3710
+ if (!nextSyllable ||
3711
+ nextSyllable.classList.contains('line-synced') ||
3712
+ nextSyllable.classList.contains('transliteration') ||
3713
+ nextSyllable.closest('.lyrics-gap') ||
3714
+ nextSyllable.classList.contains('highlight') ||
3715
+ nextSyllable.classList.contains('finished')) {
3716
+ return;
3717
+ }
3718
+ const nextStartTimeMs = nextSyllable._cachedStartTime;
3719
+ if (!Number.isFinite(nextStartTimeMs))
3720
+ return;
3721
+ const gapMs = nextStartTimeMs - currentEndTimeMs;
3722
+ if (gapMs > NEXT_WORD_PRE_WIPE_MAX_GAP_MS || gapMs < -50) {
3723
+ return;
3724
+ }
3725
+ const nextWordSyllables = AmLyrics.getRenderedWordSyllables(nextSyllable);
3726
+ const preWipeSyllables = nextWordSyllables.length > 0 ? nextWordSyllables : [nextSyllable];
3727
+ const wordElement = AmLyrics.getWordElementForSyllable(nextSyllable);
3728
+ const wordCharSpans = AmLyrics.getCachedVirtualWordCharSpans(wordElement, []);
3729
+ const charCount = wordCharSpans.length ||
3730
+ preWipeSyllables.reduce((total, wordSyllable) => total + AmLyrics.getVisibleCharacterCount(wordSyllable), 0);
3731
+ if (charCount <= 0)
3732
+ return;
3733
+ const preWipeDuration = AmLyrics.clamp(64 + charCount * 9, NEXT_WORD_PRE_WIPE_MIN_DURATION_MS, NEXT_WORD_PRE_WIPE_MAX_DURATION_MS);
3734
+ const preWipeStart = Math.max(nextStartTimeMs - preWipeDuration, currentEndTimeMs - WORD_PRE_WIPE_HANDOFF_LEAD_MS);
3735
+ if (currentTimeMs < preWipeStart || currentTimeMs >= nextStartTimeMs) {
3736
+ return;
3737
+ }
3738
+ AmLyrics.applyWordPreWipe(nextSyllable, preWipeSyllables, currentTimeMs, preWipeStart, preWipeDuration);
3739
+ }
3740
+ static getCachedCharSpans(element) {
3741
+ const cacheTarget = element;
3742
+ if (!cacheTarget._cachedCharSpans) {
3743
+ cacheTarget._cachedCharSpans = Array.from(element.querySelectorAll('span.char'));
3744
+ }
3745
+ return cacheTarget._cachedCharSpans;
3746
+ }
3747
+ static getCachedVirtualWordElements(wordElement) {
3748
+ if (!wordElement)
3749
+ return [];
3750
+ const cacheTarget = wordElement;
3751
+ if (cacheTarget._cachedVirtualWordElements) {
3752
+ return cacheTarget._cachedVirtualWordElements;
3753
+ }
3754
+ const { virtualWordId } = wordElement.dataset;
3755
+ let wordElements = [wordElement];
3756
+ if (virtualWordId && wordElement.parentElement) {
3757
+ wordElements = Array.from(wordElement.parentElement.querySelectorAll('.lyrics-word')).filter(el => el.dataset.virtualWordId === virtualWordId);
3758
+ }
3759
+ wordElements.forEach(element => {
3760
+ const target = element;
3761
+ target._cachedVirtualWordElements = wordElements;
3762
+ });
3763
+ return wordElements;
3764
+ }
3765
+ static getCachedVirtualWordCharSpans(wordElement, fallbackCharSpans) {
3766
+ if (!wordElement)
3767
+ return fallbackCharSpans;
3768
+ const cacheTarget = wordElement;
3769
+ if (cacheTarget._cachedVirtualWordCharSpans) {
3770
+ return cacheTarget._cachedVirtualWordCharSpans;
3771
+ }
3772
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
3773
+ const charSpans = wordElements.flatMap(word => Array.from(word.querySelectorAll('span.char')));
3774
+ const result = charSpans.length > 0 ? charSpans : fallbackCharSpans;
3775
+ wordElements.forEach(element => {
3776
+ const target = element;
3777
+ target._cachedVirtualWordCharSpans = result;
3778
+ });
3779
+ return result;
3780
+ }
3340
3781
  static updateSyllableAnimation(syllable, elapsedTimeMs = 0) {
3341
3782
  if (syllable.classList.contains('highlight'))
3342
3783
  return;
3343
3784
  const { classList } = syllable;
3785
+ const hadPreHighlight = classList.contains('pre-highlight');
3344
3786
  const isRTL = classList.contains('rtl-text');
3345
- const charSpans = Array.from(syllable.querySelectorAll('span.char'));
3787
+ const charSpans = AmLyrics.getCachedCharSpans(syllable);
3346
3788
  const wordElement = syllable.parentElement?.parentElement; // syllable-wrap -> word
3347
- const virtualWordId = wordElement?.dataset
3348
- .virtualWordId;
3349
- let wordElements = [];
3350
- if (virtualWordId && wordElement?.parentElement) {
3351
- wordElements = Array.from(wordElement.parentElement.querySelectorAll('.lyrics-word')).filter(el => el.dataset.virtualWordId === virtualWordId);
3352
- }
3353
- else if (wordElement) {
3354
- wordElements = [wordElement];
3355
- }
3356
- const allWordCharSpans = wordElements.flatMap(word => Array.from(word.querySelectorAll('span.char')));
3357
- const isGrowable = wordElement?.classList.contains('growable');
3358
- const isCharRise = wordElement?.classList.contains('char-rise');
3789
+ const typedWordElement = wordElement;
3790
+ const allWordElements = AmLyrics.getCachedVirtualWordElements(typedWordElement);
3791
+ const allWordCharSpans = AmLyrics.getCachedVirtualWordCharSpans(typedWordElement, charSpans);
3792
+ const isGrowable = typedWordElement?.classList.contains('growable');
3793
+ const isCharRise = typedWordElement?.classList.contains('char-rise');
3794
+ const isCharDrag = typedWordElement?.classList.contains('char-drag');
3359
3795
  const isFirstSyllable = syllable.getAttribute('data-syllable-index') === '0';
3360
3796
  const syllableStartMs = parseFloat(syllable.getAttribute('data-start-time') || '0');
3361
- const virtualWordStartMs = parseFloat(wordElement?.dataset.virtualWordStart || '');
3797
+ const virtualWordStartMs = parseFloat(typedWordElement?.dataset.virtualWordStart || '');
3362
3798
  const isFirstInVirtualWord = isFirstSyllable &&
3363
3799
  (!Number.isFinite(virtualWordStartMs) ||
3364
3800
  Math.abs(syllableStartMs - virtualWordStartMs) < 0.5);
@@ -3369,7 +3805,10 @@ class AmLyrics extends i {
3369
3805
  const wordDurationMs = parseFloat(syllable.getAttribute('data-word-duration') ||
3370
3806
  syllable.getAttribute('data-duration') ||
3371
3807
  '0') || syllableDurationMs;
3372
- // Use a Map to collect animations like YouLyPlus
3808
+ const wordElapsedTimeMs = Number.isFinite(virtualWordStartMs)
3809
+ ? elapsedTimeMs + (syllableStartMs - virtualWordStartMs)
3810
+ : elapsedTimeMs;
3811
+ const charWipeDurationMs = Math.max(wordDurationMs, syllableDurationMs);
3373
3812
  const charAnimationsMap = new Map();
3374
3813
  const styleUpdates = [];
3375
3814
  // Step 1: Grow Pass
@@ -3417,41 +3856,81 @@ class AmLyrics extends i {
3417
3856
  charAnimationsMap.set(span, `rise-char ${riseDurationMs}ms ease-in-out ${riseDelay}ms forwards`);
3418
3857
  });
3419
3858
  }
3859
+ if (isCharDrag && isFirstInVirtualWord && allWordCharSpans.length > 0) {
3860
+ const finalDuration = Math.max(wordDurationMs, syllableDurationMs);
3861
+ const baseDelayPerChar = AmLyrics.clamp(finalDuration * 0.15, 64, 118);
3862
+ const dragDurationMs = AmLyrics.clamp(finalDuration * 0.82, 560, 900);
3863
+ allWordCharSpans.forEach(span => {
3864
+ const charIndex = parseFloat(span.dataset.syllableCharIndex || '0');
3865
+ const dragDelay = baseDelayPerChar * charIndex;
3866
+ charAnimationsMap.set(span, `drag-char ${dragDurationMs}ms ease ${dragDelay}ms forwards`);
3867
+ });
3868
+ }
3420
3869
  // Step 2: Wipe Pass
3421
3870
  if (charSpans.length > 0) {
3422
- charSpans.forEach((span, charIndex) => {
3871
+ const wipeCharCount = allWordCharSpans.length ||
3872
+ charSpans.length ||
3873
+ AmLyrics.getVisibleCharacterCount(syllable);
3874
+ const wipeScale = AmLyrics.getLongWordWipeScale(wipeCharCount);
3875
+ AmLyrics.applyWipeShape(syllable, wipeCharCount);
3876
+ AmLyrics.ensureWordWipeGeometry(allWordCharSpans, wipeCharCount);
3877
+ allWordCharSpans.forEach(span => AmLyrics.applyWipeShape(span, wipeCharCount));
3878
+ const hasWordLevelWipe = !isFirstInVirtualWord &&
3879
+ (Boolean(typedWordElement?._wordWipeStarted) ||
3880
+ allWordCharSpans.some(span => span.style.animation.includes('wipe')));
3881
+ let charSpansToAnimate = charSpans;
3882
+ if (isFirstInVirtualWord) {
3883
+ charSpansToAnimate = allWordCharSpans;
3884
+ }
3885
+ else if (hasWordLevelWipe) {
3886
+ charSpansToAnimate = [];
3887
+ }
3888
+ if (charSpansToAnimate.length > 0 && allWordElements.length > 0) {
3889
+ allWordElements.forEach(element => {
3890
+ const target = element;
3891
+ target._wordWipeStarted = true;
3892
+ target._wordPreWipeKey = undefined;
3893
+ });
3894
+ }
3895
+ charSpansToAnimate.forEach((span, charIndex) => {
3423
3896
  const startPct = parseFloat(span.dataset.wipeStart || '0');
3424
3897
  const durationPct = parseFloat(span.dataset.wipeDuration || '0');
3425
- const wipeDelay = syllableDurationMs * startPct - elapsedTimeMs;
3426
- const wipeDuration = syllableDurationMs * durationPct;
3427
- const useStartAnimation = isFirstInContainer && charIndex === 0;
3428
- let charWipeAnimation = 'wipe';
3429
- if (useStartAnimation) {
3430
- charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
3898
+ const globalCharIndex = parseFloat(span.dataset.syllableCharIndex || `${charIndex}`);
3899
+ const hadCharPreWipe = span.classList.contains('pre-wipe-lead') ||
3900
+ (hadPreHighlight && globalCharIndex === 0);
3901
+ const charStartMs = charWipeDurationMs * startPct;
3902
+ const remainingWordWipeMs = Math.max(0, charWipeDurationMs - charStartMs);
3903
+ const wipeDelay = charStartMs - wordElapsedTimeMs;
3904
+ const wipeDuration = Math.min(charWipeDurationMs * durationPct * wipeScale, remainingWordWipeMs);
3905
+ const useStartAnimation = isFirstInContainer && globalCharIndex === 0 && !hadCharPreWipe;
3906
+ let charWipeAnimation = 'char-wipe';
3907
+ if (hadCharPreWipe) {
3908
+ charWipeAnimation = 'char-wipe';
3431
3909
  }
3432
- else {
3433
- charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
3910
+ else if (useStartAnimation) {
3911
+ charWipeAnimation = 'char-start-wipe';
3434
3912
  }
3435
3913
  const existingAnimation = charAnimationsMap.get(span) || span.style.animation || '';
3436
3914
  const animationParts = [];
3437
3915
  if (existingAnimation &&
3438
3916
  (existingAnimation.includes('grow-dynamic') ||
3439
- existingAnimation.includes('rise-char'))) {
3917
+ existingAnimation.includes('rise-char') ||
3918
+ existingAnimation.includes('drag-char'))) {
3440
3919
  animationParts.push(existingAnimation.split(',')[0].trim());
3441
3920
  }
3442
- if (charIndex > 0 && wipeDelay > 0 && wipeDuration > 0) {
3443
- const arrivalTime = (span.dataset.preWipeArrival
3444
- ? parseFloat(span.dataset.preWipeArrival)
3445
- : syllableDurationMs * startPct) - elapsedTimeMs;
3446
- const measuredPreWipeDuration = parseFloat(span.dataset.preWipeDuration || '100');
3447
- const preWipeDuration = Math.min(measuredPreWipeDuration, wipeDuration * 0.9, syllableDurationMs * 0.08, arrivalTime);
3448
- const animDelay = arrivalTime - preWipeDuration;
3921
+ if (globalCharIndex > 0 &&
3922
+ !hadCharPreWipe &&
3923
+ wipeDelay > 0 &&
3924
+ wipeDuration > 0) {
3925
+ const measuredDuration = Number.parseFloat(span.dataset.preWipeDuration || '100');
3926
+ const preWipeDuration = Math.min(measuredDuration, wipeDuration * 0.9, charWipeDurationMs * 0.08, wipeDelay);
3449
3927
  if (preWipeDuration >= 16) {
3450
- animationParts.push(`pre-wipe-char ${preWipeDuration}ms linear ${animDelay}ms none`);
3928
+ animationParts.push(`char-pre-wipe ${preWipeDuration}ms linear ${wipeDelay - preWipeDuration}ms none`);
3451
3929
  }
3452
3930
  }
3453
3931
  if (wipeDuration > 0) {
3454
- animationParts.push(`${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`);
3932
+ const wipeFillMode = hadCharPreWipe ? 'both' : 'forwards';
3933
+ animationParts.push(`${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms ${wipeFillMode}`);
3455
3934
  }
3456
3935
  if (animationParts.length > 0) {
3457
3936
  charAnimationsMap.set(span, animationParts.join(', '));
@@ -3461,9 +3940,15 @@ class AmLyrics extends i {
3461
3940
  else {
3462
3941
  // Syllable-level wipe for regular (non-growable) words without chars
3463
3942
  const wipeRatio = parseFloat(syllable.getAttribute('data-wipe-ratio') || '1');
3464
- const visualDuration = syllableDurationMs * wipeRatio;
3943
+ const wipeCharCount = AmLyrics.getVisibleCharacterCount(syllable);
3944
+ const wipeScale = AmLyrics.getLongWordWipeScale(wipeCharCount);
3945
+ const visualDuration = syllableDurationMs * wipeRatio * wipeScale;
3946
+ AmLyrics.applyWipeShape(syllable, wipeCharCount);
3465
3947
  let wipeAnimation = 'wipe';
3466
- if (isFirstInContainer) {
3948
+ if (hadPreHighlight) {
3949
+ wipeAnimation = isRTL ? 'wipe-from-pre-rtl' : 'wipe-from-pre';
3950
+ }
3951
+ else if (isFirstInContainer) {
3467
3952
  wipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
3468
3953
  }
3469
3954
  else {
@@ -3476,16 +3961,25 @@ class AmLyrics extends i {
3476
3961
  syllable.style.animation = `${currentWipeAnimation} ${visualDuration}ms ${isGap ? 'ease-out' : 'linear'} ${-elapsedTimeMs}ms forwards`;
3477
3962
  }
3478
3963
  // --- WRITE PHASE ---
3964
+ if (allWordElements.length > 0) {
3965
+ allWordElements.forEach(element => {
3966
+ const target = element;
3967
+ target._wordPreWipeKey = undefined;
3968
+ });
3969
+ }
3479
3970
  classList.remove('pre-highlight');
3480
3971
  classList.add('highlight');
3972
+ allWordCharSpans.forEach(span => AmLyrics.clearPreWipeLead(span));
3973
+ // Apply keyframe variables before assigning animation strings so the
3974
+ // first painted frame never uses fallback transform values.
3975
+ for (const update of styleUpdates) {
3976
+ update.element.style.setProperty(update.property, update.value);
3977
+ }
3481
3978
  for (const [span, animationString] of charAnimationsMap.entries()) {
3482
3979
  span.style.willChange = 'transform';
3980
+ span.style.removeProperty('background-color');
3483
3981
  span.style.animation = animationString;
3484
3982
  }
3485
- // Apply style updates
3486
- for (const update of styleUpdates) {
3487
- update.element.style.setProperty(update.property, update.value);
3488
- }
3489
3983
  }
3490
3984
  /**
3491
3985
  * Reset syllable animation state
@@ -3509,10 +4003,19 @@ class AmLyrics extends i {
3509
4003
  el.style.animation = '';
3510
4004
  el.style.transition = 'none';
3511
4005
  el.style.backgroundColor = 'var(--lyplus-text-secondary)';
4006
+ AmLyrics.clearPreWipeLead(el);
3512
4007
  }
3513
4008
  // Immediately remove all state classes
3514
4009
  syllable.classList.remove('highlight', 'finished', 'pre-highlight', 'cleanup');
3515
4010
  }
4011
+ static resetWordAnimationState(line) {
4012
+ const wordElements = line.querySelectorAll('.lyrics-word');
4013
+ wordElements.forEach(wordElement => {
4014
+ const target = wordElement;
4015
+ target._wordPreWipeKey = undefined;
4016
+ target._wordWipeStarted = false;
4017
+ });
4018
+ }
3516
4019
  /**
3517
4020
  * Reset all syllables in a line — batches deferred cleanup into a single rAF
3518
4021
  */
@@ -3520,6 +4023,7 @@ class AmLyrics extends i {
3520
4023
  if (!line)
3521
4024
  return;
3522
4025
  line.classList.remove('persist-highlight');
4026
+ AmLyrics.resetWordAnimationState(line);
3523
4027
  // eslint-disable-next-line no-param-reassign
3524
4028
  line._cachedSyllableElements = null;
3525
4029
  const syllables = line.getElementsByClassName('lyrics-syllable');
@@ -3551,6 +4055,7 @@ class AmLyrics extends i {
3551
4055
  if (!line)
3552
4056
  return;
3553
4057
  line.classList.remove('persist-highlight');
4058
+ AmLyrics.resetWordAnimationState(line);
3554
4059
  const syllables = line.getElementsByClassName('lyrics-syllable');
3555
4060
  for (let i = 0; i < syllables.length; i += 1) {
3556
4061
  const s = syllables[i];
@@ -3568,6 +4073,7 @@ class AmLyrics extends i {
3568
4073
  el.style.removeProperty('background-color');
3569
4074
  el.style.removeProperty('transition');
3570
4075
  el.style.removeProperty('filter');
4076
+ AmLyrics.clearPreWipeLead(el);
3571
4077
  }
3572
4078
  }
3573
4079
  }
@@ -3604,19 +4110,26 @@ class AmLyrics extends i {
3604
4110
  syllable.style.animation = '';
3605
4111
  syllable.style.removeProperty('--pre-wipe-duration');
3606
4112
  syllable.style.removeProperty('--pre-wipe-delay');
4113
+ syllable.style.removeProperty('background-color');
4114
+ AmLyrics.applyWipeShape(syllable, AmLyrics.getVisibleCharacterCount(syllable));
3607
4115
  const chars = syllable.querySelectorAll('span.char');
3608
4116
  for (let ci = 0; ci < chars.length; ci += 1) {
3609
4117
  const charEl = chars[ci];
3610
4118
  const currentAnim = charEl.style.animation || '';
3611
4119
  if (currentAnim.includes('grow-dynamic') ||
3612
- currentAnim.includes('rise-char')) {
4120
+ currentAnim.includes('rise-char') ||
4121
+ currentAnim.includes('drag-char')) {
3613
4122
  const parts = currentAnim.split(',').map(p => p.trim());
3614
- const transformAnim = parts.find(p => p.includes('grow-dynamic') || p.includes('rise-char'));
4123
+ const transformAnim = parts.find(p => p.includes('grow-dynamic') ||
4124
+ p.includes('rise-char') ||
4125
+ p.includes('drag-char'));
3615
4126
  charEl.style.animation = transformAnim || '';
3616
4127
  }
3617
4128
  else {
3618
4129
  charEl.style.animation = '';
3619
4130
  }
4131
+ charEl.style.backgroundColor = 'var(--lyplus-text-primary)';
4132
+ AmLyrics.clearPreWipeLead(charEl);
3620
4133
  }
3621
4134
  }
3622
4135
  }
@@ -3657,14 +4170,16 @@ class AmLyrics extends i {
3657
4170
  // Early exit check
3658
4171
  if (!(currentTimeMs < startTime - 1000 && !hasActiveState)) {
3659
4172
  let preHighlightReset = false;
3660
- // Pre-highlight reset logic
3661
- if (hasPreHighlight && i > 0) {
3662
- const prevSyllable = syllables[i - 1];
3663
- if (!prevSyllable.classList.contains('highlight')) {
3664
- classList.remove('pre-highlight');
3665
- syllable.style.removeProperty('--pre-wipe-duration');
3666
- syllable.style.removeProperty('--pre-wipe-delay');
3667
- syllable.style.animation = '';
4173
+ // Before the syllable starts, pre-highlight only belongs beside a
4174
+ // previous active word. Once the syllable starts, updateSyllableAnimation
4175
+ // consumes the class so the actual wipe can continue from the pre-wipe
4176
+ // pose instead of restarting from the beginning.
4177
+ if (hasPreHighlight && currentTimeMs < startTime) {
4178
+ const prevSyllable = AmLyrics.getPreviousNonTransliterationSyllable(syllables, i);
4179
+ const previousCarriesHighlight = prevSyllable?.classList.contains('highlight') ||
4180
+ prevSyllable?.classList.contains('finished');
4181
+ if (!previousCarriesHighlight) {
4182
+ AmLyrics.clearPreHighlight(syllable);
3668
4183
  preHighlightReset = true;
3669
4184
  }
3670
4185
  }
@@ -3692,6 +4207,7 @@ class AmLyrics extends i {
3692
4207
  // Not yet started
3693
4208
  AmLyrics.resetSyllable(syllable);
3694
4209
  }
4210
+ AmLyrics.maybePreWipeNextWord(syllables, i, currentTimeMs, endTime);
3695
4211
  }
3696
4212
  }
3697
4213
  }
@@ -4001,6 +4517,9 @@ class AmLyrics extends i {
4001
4517
  data-end-time="${endTimeMs}"
4002
4518
  data-duration="${durationMs}"
4003
4519
  data-syllable-index="${syllableIndex}"
4520
+ data-word-index="${syllableIndex}"
4521
+ data-word-length="${syllable.text.replace(/\s/g, '')
4522
+ .length}"
4004
4523
  data-wipe-ratio="1"
4005
4524
  >${syllable.text}</span
4006
4525
  >${bgRomanizedText}</span
@@ -4022,13 +4541,13 @@ class AmLyrics extends i {
4022
4541
  const groupGrowable = lineData?.groupGrowable ?? [];
4023
4542
  const groupGlowing = lineData?.groupGlowing ?? [];
4024
4543
  const groupCharRise = lineData?.groupCharRise ?? [];
4544
+ const groupCharDrag = lineData?.groupCharDrag ?? [];
4025
4545
  const vwFullText = lineData?.vwFullText ?? [];
4026
4546
  const vwFullDuration = lineData?.vwFullDuration ?? [];
4027
4547
  const vwCharOffset = lineData?.vwCharOffset ?? [];
4028
4548
  const vwStartMs = lineData?.vwStartMs ?? [];
4029
4549
  const vwEndMs = lineData?.vwEndMs ?? [];
4030
4550
  const lineIsRTL = lineData?.lineIsRTL ?? false;
4031
- // Create main vocals using YouLyPlus syllable structure
4032
4551
  const mainVocalElement = b `<p
4033
4552
  class="main-vocal-container ${lineIsRTL ? 'rtl-text' : ''}"
4034
4553
  >
@@ -4036,25 +4555,23 @@ class AmLyrics extends i {
4036
4555
  const isGrowable = groupGrowable[groupIdx];
4037
4556
  const isGlowing = groupGlowing[groupIdx];
4038
4557
  const isCharRise = groupCharRise[groupIdx];
4039
- const isAnimatedByChar = isGrowable || isCharRise;
4558
+ const isCharDrag = groupCharDrag[groupIdx];
4559
+ const isAnimatedByChar = isGrowable || isCharRise || isCharDrag;
4040
4560
  const groupLineSynced = group.some(s => s.lineSynced);
4041
4561
  const wordText = isAnimatedByChar ? vwFullText[groupIdx] : '';
4042
4562
  const wordDuration = isAnimatedByChar
4043
4563
  ? vwFullDuration[groupIdx]
4044
4564
  : 0;
4045
- const wordNumChars = wordText.length;
4565
+ const wordNumChars = wordText.replace(/\s/g, '').length;
4046
4566
  const groupCharOffset = isAnimatedByChar
4047
4567
  ? vwCharOffset[groupIdx]
4048
4568
  : 0;
4049
- const virtualWordId = isAnimatedByChar
4050
- ? `${lineIndex}:${vwStartMs[groupIdx]}:${vwEndMs[groupIdx]}`
4051
- : '';
4052
- const virtualWordStart = isAnimatedByChar
4053
- ? vwStartMs[groupIdx]
4054
- : '';
4055
- const virtualWordEnd = isAnimatedByChar ? vwEndMs[groupIdx] : '';
4569
+ const virtualWordId = `${lineIndex}:${vwStartMs[groupIdx]}:${vwEndMs[groupIdx]}`;
4570
+ const virtualWordStart = vwStartMs[groupIdx];
4571
+ const virtualWordEnd = vwEndMs[groupIdx];
4056
4572
  let sylCharAccumulator = 0;
4057
4573
  const groupText = group.map(s => s.text).join('');
4574
+ const visibleWordLength = groupText.replace(/\s/g, '').length;
4058
4575
  const shouldAllowBreak = groupText.trim().length >= 16 ||
4059
4576
  /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(groupText);
4060
4577
  // Calculate dynamic rise duration based on the audio duration of the word
@@ -4066,9 +4583,9 @@ class AmLyrics extends i {
4066
4583
  return b `<span
4067
4584
  class="lyrics-word${isGrowable ? ' growable' : ''}${isCharRise
4068
4585
  ? ' char-rise'
4069
- : ''}${isGlowing ? ' glowing' : ''}${shouldAllowBreak
4070
- ? ' allow-break'
4071
- : ''}"
4586
+ : ''}${isCharDrag ? ' char-drag' : ''}${isGlowing
4587
+ ? ' glowing'
4588
+ : ''}${shouldAllowBreak ? ' allow-break' : ''}"
4072
4589
  data-virtual-word-id="${virtualWordId}"
4073
4590
  data-virtual-word-start="${virtualWordStart}"
4074
4591
  data-virtual-word-end="${virtualWordEnd}"
@@ -4095,13 +4612,26 @@ class AmLyrics extends i {
4095
4612
  : '';
4096
4613
  let syllableContent = text;
4097
4614
  if (isAnimatedByChar) {
4098
- let charIndexInsideSyllable = 0;
4099
4615
  const numCharsInSyllable = text.replace(/\s/g, '').length || 1;
4616
+ const hasVirtualTiming = wordDuration > 0 && Number.isFinite(virtualWordStart);
4617
+ const syllableStartRatio = hasVirtualTiming
4618
+ ? AmLyrics.clamp((startTimeMs - virtualWordStart) / wordDuration, 0, 1)
4619
+ : 0;
4620
+ const syllableDurationRatio = hasVirtualTiming
4621
+ ? AmLyrics.clamp(durationMs / wordDuration, 0, 1)
4622
+ : 1;
4623
+ let charIndexInsideSyllable = 0;
4100
4624
  syllableContent = b `${text.split('').map(char => {
4101
4625
  if (char === ' ')
4102
4626
  return ' ';
4103
4627
  const charIndexInsideWord = groupCharOffset + sylCharAccumulator;
4104
- const charStartPercentVal = charIndexInsideSyllable / numCharsInSyllable;
4628
+ const localCharIndex = charIndexInsideSyllable;
4629
+ const visibleWordChars = Math.max(1, wordNumChars);
4630
+ const charStartPercentVal = AmLyrics.clamp(syllableStartRatio +
4631
+ (localCharIndex / numCharsInSyllable) *
4632
+ syllableDurationRatio, 0, 1);
4633
+ const charDurationPercentVal = syllableDurationRatio / numCharsInSyllable ||
4634
+ 1 / visibleWordChars;
4105
4635
  sylCharAccumulator += 1;
4106
4636
  charIndexInsideSyllable += 1;
4107
4637
  const minDuration = 400;
@@ -4148,21 +4678,37 @@ class AmLyrics extends i {
4148
4678
  const normalizedGrowth = (charMaxScale - 1.0) / 0.1;
4149
4679
  const effectiveDuration = (wordDuration + durationMs * 2) / 3;
4150
4680
  const peakMultiplier = Math.min(1, Math.max(0.3, effectiveDuration / 2000));
4151
- const charTranslateYPeak = -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
4681
+ const baseTranslateYPeak = -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
4152
4682
  const position = (charIndexInsideWord + 0.5) / wordNumChars;
4153
4683
  const horizontalOffset = (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25);
4684
+ const isDragMotion = isCharDrag;
4685
+ let charTranslateYPeak = baseTranslateYPeak;
4686
+ if (isCharRise) {
4687
+ charTranslateYPeak = 0;
4688
+ }
4689
+ else if (isDragMotion) {
4690
+ charTranslateYPeak = -0.78;
4691
+ }
4692
+ let motionHorizontalOffset = horizontalOffset;
4693
+ if (isCharRise) {
4694
+ motionHorizontalOffset = 0;
4695
+ }
4696
+ else if (isDragMotion) {
4697
+ motionHorizontalOffset = 0;
4698
+ }
4154
4699
  return b `<span
4155
4700
  class="char"
4156
4701
  data-char-index="${charIndexInsideWord}"
4157
4702
  data-syllable-char-index="${charIndexInsideWord}"
4158
4703
  data-wipe-start="${charStartPercentVal.toFixed(4)}"
4159
- data-wipe-duration="${(1 / numCharsInSyllable).toFixed(4)}"
4704
+ data-wipe-duration="${charDurationPercentVal.toFixed(4)}"
4160
4705
  data-horizontal-offset="${horizontalOffset.toFixed(2)}"
4161
4706
  data-max-scale="${charMaxScale.toFixed(3)}"
4162
4707
  data-matrix-scale="${(charMaxScale * 0.98).toFixed(3)}"
4163
- data-char-offset-x="${(horizontalOffset * 0.98).toFixed(2)}"
4708
+ data-char-offset-x="${(motionHorizontalOffset * 0.98).toFixed(2)}"
4164
4709
  data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
4165
4710
  data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
4711
+ style="--word-wipe-width: ${visibleWordChars}ch; --char-wipe-position: -${charIndexInsideWord}ch"
4166
4712
  >${char}</span
4167
4713
  >`;
4168
4714
  })}`;
@@ -4180,6 +4726,8 @@ class AmLyrics extends i {
4180
4726
  data-duration="${durationMs}"
4181
4727
  data-word-duration="${wordDuration}"
4182
4728
  data-syllable-index="${sylIdx}"
4729
+ data-word-index="${groupIdx}"
4730
+ data-word-length="${visibleWordLength}"
4183
4731
  data-wipe-ratio="1"
4184
4732
  >${syllableContent}</span
4185
4733
  >${romanizedText}</span
@@ -4412,13 +4960,14 @@ class AmLyrics extends i {
4412
4960
  <button
4413
4961
  class="download-button source-switch-btn"
4414
4962
  title="Switch Lyrics Source"
4415
- 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;"
4416
4963
  @click=${this.switchSource}
4417
4964
  ?disabled=${this.isFetchingAlternatives}
4418
4965
  >
4419
4966
  <svg
4420
- class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
4421
- style="margin-right: 4px;"
4967
+ class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up ${this
4968
+ .isFetchingAlternatives
4969
+ ? 'is-loading'
4970
+ : ''}"
4422
4971
  xmlns="http://www.w3.org/2000/svg"
4423
4972
  width="12"
4424
4973
  height="12"
@@ -4476,9 +5025,6 @@ class AmLyrics extends i {
4476
5025
  }
4477
5026
  }
4478
5027
  AmLyrics.styles = i$3 `
4479
- /* ==========================================================================
4480
- YOULYPLUS-INSPIRED STYLING - Design Tokens & Variables
4481
- ========================================================================== */
4482
5028
  :host {
4483
5029
  --lyplus-lyrics-palette: var(
4484
5030
  --am-lyrics-highlight-color,
@@ -4507,6 +5053,8 @@ AmLyrics.styles = i$3 `
4507
5053
  --lyplus-blur-amount: 0.07em;
4508
5054
  --lyplus-blur-amount-near: 0.035em;
4509
5055
  --lyplus-fade-gap-timing-function: ease-out;
5056
+ --wipe-gradient-width: 0.75em;
5057
+ --wipe-gradient-half: 0.375em;
4510
5058
 
4511
5059
  --lyrics-scroll-padding-top: 25%;
4512
5060
 
@@ -4534,6 +5082,9 @@ AmLyrics.styles = i$3 `
4534
5082
  max-height: 100vh;
4535
5083
  overflow-y: auto;
4536
5084
  -webkit-overflow-scrolling: touch;
5085
+ -webkit-touch-callout: none;
5086
+ -webkit-user-select: none;
5087
+ user-select: none;
4537
5088
  box-sizing: border-box;
4538
5089
  scrollbar-width: none;
4539
5090
  overflow-anchor: none;
@@ -4663,7 +5214,7 @@ AmLyrics.styles = i$3 `
4663
5214
  .lyrics-line.bg-expanded .background-vocal-container {
4664
5215
  max-height: 4em;
4665
5216
  opacity: 1;
4666
- will-change: max-height, opacity;
5217
+ will-change: opacity;
4667
5218
  }
4668
5219
 
4669
5220
  .lyrics-line.bg-expanded .background-vocal-wrap {
@@ -4680,6 +5231,18 @@ AmLyrics.styles = i$3 `
4680
5231
  opacity: 1;
4681
5232
  }
4682
5233
 
5234
+ /* Predictive scrolling begins before the next timestamp. Start dimming
5235
+ the outgoing line at the same moment so it settles with the scroll. */
5236
+ .lyrics-line.scroll-exiting {
5237
+ opacity: 0.8;
5238
+ color: var(--lyplus-text-secondary);
5239
+ transition:
5240
+ opacity var(--scroll-duration, 400ms) cubic-bezier(0.41, 0, 0.12, 0.99),
5241
+ transform var(--scroll-duration, 400ms)
5242
+ cubic-bezier(0.41, 0, 0.12, 0.99) var(--lyrics-line-delay, 0ms),
5243
+ filter var(--scroll-duration, 400ms) ease;
5244
+ }
5245
+
4683
5246
  .lyrics-line.persist-highlight {
4684
5247
  filter: none !important;
4685
5248
  opacity: 1;
@@ -4815,11 +5378,22 @@ AmLyrics.styles = i$3 `
4815
5378
  white-space: nowrap;
4816
5379
  }
4817
5380
 
5381
+ .lyrics-word.char-drag {
5382
+ display: inline-block;
5383
+ vertical-align: baseline;
5384
+ white-space: nowrap;
5385
+ }
5386
+
4818
5387
  .lyrics-word.char-rise.allow-break {
4819
5388
  display: inline;
4820
5389
  white-space: normal;
4821
5390
  }
4822
5391
 
5392
+ .lyrics-word.char-drag.allow-break {
5393
+ display: inline;
5394
+ white-space: normal;
5395
+ }
5396
+
4823
5397
  .lyrics-syllable-wrap {
4824
5398
  display: inline;
4825
5399
  }
@@ -4874,44 +5448,30 @@ AmLyrics.styles = i$3 `
4874
5448
  .lyrics-line.active:not(.lyrics-gap)
4875
5449
  .lyrics-syllable.pre-highlight.no-chars {
4876
5450
  background-repeat: no-repeat;
4877
- background-image:
4878
- linear-gradient(
4879
- 90deg,
4880
- #ffffff00 0%,
4881
- var(--lyplus-text-primary, #fff) 50%,
4882
- #0000 100%
4883
- ),
4884
- linear-gradient(
4885
- 90deg,
4886
- var(--lyplus-text-primary, #fff) 100%,
4887
- #0000 100%
4888
- );
4889
- background-size:
4890
- 0.5em 100%,
4891
- 0% 100%;
4892
- background-position:
4893
- -0.5em 0%,
4894
- -0.25em 0%;
5451
+ background-image: linear-gradient(
5452
+ 90deg,
5453
+ var(--lyplus-text-primary, #fff) 0%,
5454
+ var(--lyplus-text-primary, #fff)
5455
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5456
+ #0000 100%
5457
+ );
5458
+ background-size: 0% 100%;
5459
+ background-position: left;
4895
5460
  }
4896
5461
 
4897
5462
  .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.highlight.rtl-text,
4898
5463
  .lyrics-line.active:not(.lyrics-gap)
4899
5464
  .lyrics-syllable.pre-highlight.rtl-text {
4900
5465
  direction: rtl;
4901
- background-image:
4902
- linear-gradient(
4903
- -90deg,
4904
- var(--lyplus-text-primary) 0%,
4905
- transparent 100%
4906
- ),
4907
- linear-gradient(
4908
- -90deg,
4909
- var(--lyplus-text-primary) 100%,
4910
- transparent 100%
4911
- );
4912
- background-position:
4913
- calc(100% + 0.5em) 0%,
4914
- right 0%;
5466
+ background-image: linear-gradient(
5467
+ -90deg,
5468
+ var(--lyplus-text-primary) 0%,
5469
+ var(--lyplus-text-primary)
5470
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5471
+ transparent 100%
5472
+ );
5473
+ background-size: 0% 100%;
5474
+ background-position: right 0%;
4915
5475
  }
4916
5476
 
4917
5477
  /* Background vocals: muted gray wipe instead of white.
@@ -4928,18 +5488,13 @@ AmLyrics.styles = i$3 `
4928
5488
  .lyrics-line.pre-active
4929
5489
  .background-vocal-container
4930
5490
  .lyrics-syllable.pre-highlight.no-chars {
4931
- background-image:
4932
- linear-gradient(
4933
- 90deg,
4934
- #ffffff00 0%,
4935
- color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 50%,
4936
- #0000 100%
4937
- ),
4938
- linear-gradient(
4939
- 90deg,
4940
- color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 100%,
4941
- #0000 100%
4942
- );
5491
+ background-image: linear-gradient(
5492
+ 90deg,
5493
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 0%,
5494
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888)
5495
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5496
+ #0000 100%
5497
+ );
4943
5498
  }
4944
5499
 
4945
5500
  .lyrics-line.active
@@ -4954,17 +5509,13 @@ AmLyrics.styles = i$3 `
4954
5509
  .lyrics-line.pre-active
4955
5510
  .background-vocal-container
4956
5511
  .lyrics-syllable.pre-highlight.rtl-text {
4957
- background-image:
4958
- linear-gradient(
4959
- -90deg,
4960
- color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 0%,
4961
- transparent 100%
4962
- ),
4963
- linear-gradient(
4964
- -90deg,
4965
- color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 100%,
4966
- transparent 100%
4967
- );
5512
+ background-image: linear-gradient(
5513
+ -90deg,
5514
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 0%,
5515
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888)
5516
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5517
+ transparent 100%
5518
+ );
4968
5519
  }
4969
5520
 
4970
5521
  /* Non-growable words float up with a gentle curve */
@@ -4988,6 +5539,10 @@ AmLyrics.styles = i$3 `
4988
5539
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
4989
5540
  }
4990
5541
 
5542
+ .lyrics-word.char-drag .lyrics-syllable.cleanup .char {
5543
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
5544
+ }
5545
+
4991
5546
  .lyrics-line.persist-highlight
4992
5547
  .lyrics-word.growable
4993
5548
  .lyrics-syllable.finished
@@ -4995,6 +5550,10 @@ AmLyrics.styles = i$3 `
4995
5550
  .lyrics-line.persist-highlight
4996
5551
  .lyrics-word.char-rise
4997
5552
  .lyrics-syllable.finished
5553
+ .char,
5554
+ .lyrics-line.persist-highlight
5555
+ .lyrics-word.char-drag
5556
+ .lyrics-syllable.finished
4998
5557
  .char {
4999
5558
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
5000
5559
  }
@@ -5105,6 +5664,10 @@ AmLyrics.styles = i$3 `
5105
5664
  transform 0.7s ease;
5106
5665
  }
5107
5666
 
5667
+ .lyrics-word.char-drag span.char {
5668
+ transition: color 0.18s;
5669
+ }
5670
+
5108
5671
  /* Active char spans: structural only, wipe animation sets gradient */
5109
5672
  .lyrics-line.active .lyrics-syllable span.char {
5110
5673
  background-clip: text;
@@ -5123,52 +5686,34 @@ AmLyrics.styles = i$3 `
5123
5686
  #0000 100%
5124
5687
  );
5125
5688
  background-size:
5126
- 0.5em 100%,
5689
+ var(--wipe-gradient-width, 0.75em) 100%,
5127
5690
  0% 100%;
5128
5691
  background-position:
5129
- -0.5em 0%,
5130
- -0.25em 0%;
5692
+ calc(-1 * var(--wipe-gradient-width, 0.75em)) 0%,
5693
+ left;
5131
5694
  transition:
5132
5695
  transform 0.7s ease,
5133
5696
  color 0.18s;
5134
5697
  }
5135
5698
 
5136
5699
  .lyrics-line.active .lyrics-syllable span.char.highlight {
5137
- background-image:
5138
- linear-gradient(
5139
- -90deg,
5140
- var(--lyplus-text-primary, #fff) 0%,
5141
- #0000 100%
5142
- ),
5143
- linear-gradient(
5144
- -90deg,
5145
- var(--lyplus-text-primary, #fff) 100%,
5146
- #0000 100%
5147
- );
5148
- background-position:
5149
- calc(100% + 0.5em) 0%,
5150
- calc(100% + 0.25em) 0%;
5700
+ background-image: linear-gradient(
5701
+ -90deg,
5702
+ var(--lyplus-text-primary, #fff) 0%,
5703
+ var(--lyplus-text-primary, #fff)
5704
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5705
+ #0000 100%
5706
+ );
5707
+ background-size: 0% 100%;
5708
+ background-position: right 0%;
5151
5709
  }
5152
5710
 
5153
- .lyrics-line.active .lyrics-syllable.pre-highlight span.char {
5154
- background-image:
5155
- linear-gradient(
5156
- 90deg,
5157
- #ffffff00 0%,
5158
- var(--lyplus-text-primary, #fff) 50%,
5159
- #0000 100%
5160
- ),
5161
- linear-gradient(
5162
- 90deg,
5163
- var(--lyplus-text-primary, #fff) 100%,
5164
- #0000 100%
5165
- );
5166
- background-size:
5167
- 0.75em 100%,
5168
- 0% 100%;
5169
- background-position:
5170
- -0.85em 0%,
5171
- -0.25em 0%;
5711
+ .lyrics-line.active .lyrics-syllable span.char.pre-wipe-lead {
5712
+ animation-name: char-pre-wipe;
5713
+ animation-duration: var(--pre-wipe-duration);
5714
+ animation-delay: var(--pre-wipe-delay);
5715
+ animation-timing-function: linear;
5716
+ animation-fill-mode: forwards;
5172
5717
  }
5173
5718
 
5174
5719
  /* ==========================================================================
@@ -5245,13 +5790,7 @@ AmLyrics.styles = i$3 `
5245
5790
  color: var(--lyplus-text-primary) !important;
5246
5791
  }
5247
5792
 
5248
- .lyrics-line.pre-active .lyrics-syllable.line-synced {
5249
- animation: fade-in-line 0.14s ease-out forwards !important;
5250
- color: var(--lyplus-text-primary) !important;
5251
- }
5252
-
5253
- .lyrics-line.active .lyrics-syllable.line-synced span.char,
5254
- .lyrics-line.pre-active .lyrics-syllable.line-synced span.char {
5793
+ .lyrics-line.active .lyrics-syllable.line-synced span.char {
5255
5794
  background-image: none !important;
5256
5795
  background-color: var(--lyplus-text-primary) !important;
5257
5796
  transition: background-color 120ms ease-out !important;
@@ -5424,6 +5963,49 @@ AmLyrics.styles = i$3 `
5424
5963
  gap: 4px;
5425
5964
  }
5426
5965
 
5966
+ .source-switch-btn {
5967
+ position: relative;
5968
+ display: inline-flex;
5969
+ align-items: center;
5970
+ padding: 2px 8px;
5971
+ border: 1px solid rgba(255, 255, 255, 0.2);
5972
+ min-height: 28px;
5973
+ background: transparent;
5974
+ border-radius: 6px;
5975
+ color: #aaa;
5976
+ cursor: pointer;
5977
+ font-family: inherit;
5978
+ font-size: 11px;
5979
+ transition:
5980
+ color 0.2s ease,
5981
+ border-color 0.2s ease,
5982
+ background-color 0.2s ease,
5983
+ transform 0.12s ease;
5984
+ }
5985
+
5986
+ .source-switch-btn::before {
5987
+ content: '';
5988
+ position: absolute;
5989
+ inset: -6px;
5990
+ }
5991
+
5992
+ .source-switch-btn:active:not(:disabled) {
5993
+ transform: scale(0.96);
5994
+ }
5995
+
5996
+ .source-switch-btn:disabled {
5997
+ cursor: default;
5998
+ opacity: 0.7;
5999
+ }
6000
+
6001
+ .source-switch-svg {
6002
+ margin-right: 4px;
6003
+ }
6004
+
6005
+ .source-switch-svg.is-loading {
6006
+ animation: source-switch-spin 1s linear infinite;
6007
+ }
6008
+
5427
6009
  .control-button {
5428
6010
  background: transparent;
5429
6011
  border: 1px solid rgba(255, 255, 255, 0.3);
@@ -5432,7 +6014,10 @@ AmLyrics.styles = i$3 `
5432
6014
  font-size: 0.8em;
5433
6015
  color: rgba(255, 255, 255, 0.6);
5434
6016
  cursor: pointer;
5435
- transition: all 0.2s;
6017
+ transition:
6018
+ color 0.2s,
6019
+ border-color 0.2s,
6020
+ background-color 0.2s;
5436
6021
  font-weight: normal;
5437
6022
  }
5438
6023
 
@@ -5570,136 +6155,157 @@ AmLyrics.styles = i$3 `
5570
6155
  KEYFRAME ANIMATIONS
5571
6156
  ========================================================================== */
5572
6157
 
6158
+ @keyframes source-switch-spin {
6159
+ to {
6160
+ transform: rotate(360deg);
6161
+ }
6162
+ }
6163
+
5573
6164
  /* Wipe animation for syllables */
5574
6165
  @keyframes wipe {
5575
6166
  from {
5576
- background-size:
5577
- 0.75em 100%,
5578
- 0% 100%;
5579
- background-position:
5580
- -0.375em 0%,
5581
- left;
6167
+ background-size: 0% 100%;
6168
+ background-position: left;
5582
6169
  }
5583
6170
  to {
5584
- background-size:
5585
- 0.75em 100%,
5586
- 100% 100%;
5587
- background-position:
5588
- calc(100% + 0.375em) 0%,
5589
- left;
6171
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6172
+ background-position: left;
6173
+ }
6174
+ }
6175
+
6176
+ @keyframes wipe-from-pre {
6177
+ from {
6178
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6179
+ background-position: left;
6180
+ }
6181
+ to {
6182
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6183
+ background-position: left;
5590
6184
  }
5591
6185
  }
5592
6186
 
5593
6187
  @keyframes start-wipe {
5594
6188
  0% {
5595
- background-size:
5596
- 0.75em 100%,
5597
- 0% 100%;
5598
- background-position:
5599
- -0.75em 0%,
5600
- -0.375em 0%;
6189
+ background-size: 0% 100%;
6190
+ background-position: left;
5601
6191
  }
5602
6192
  100% {
5603
- background-size:
5604
- 0.75em 100%,
5605
- 100% 100%;
5606
- background-position:
5607
- calc(100% + 0.375em) 0%,
5608
- left;
6193
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6194
+ background-position: left;
5609
6195
  }
5610
6196
  }
5611
6197
 
5612
6198
  @keyframes wipe-rtl {
5613
6199
  from {
5614
- background-size:
5615
- 0.75em 100%,
5616
- 0% 100%;
5617
- background-position:
5618
- calc(100% + 0.375em) 0%,
5619
- calc(100% + 0.36em) 0%;
6200
+ background-size: 0% 100%;
6201
+ background-position: right 0%;
5620
6202
  }
5621
6203
  to {
5622
- background-size:
5623
- 0.75em 100%,
5624
- 100% 100%;
5625
- background-position:
5626
- -0.75em 0%,
5627
- right 0%;
6204
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6205
+ background-position: right 0%;
6206
+ }
6207
+ }
6208
+
6209
+ @keyframes wipe-from-pre-rtl {
6210
+ from {
6211
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6212
+ background-position: right 0%;
6213
+ }
6214
+ to {
6215
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6216
+ background-position: right 0%;
5628
6217
  }
5629
6218
  }
5630
6219
 
5631
6220
  @keyframes start-wipe-rtl {
5632
6221
  0% {
5633
- background-size:
5634
- 0.75em 100%,
5635
- 0% 100%;
5636
- background-position:
5637
- calc(100% + 0.75em) 0%,
5638
- calc(100% + 0.5em) 0%;
6222
+ background-size: 0% 100%;
6223
+ background-position: right 0%;
5639
6224
  }
5640
6225
  100% {
5641
- background-size:
5642
- 0.75em 100%,
5643
- 100% 100%;
5644
- background-position:
5645
- -0.75em 0%,
5646
- right 0%;
6226
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6227
+ background-position: right 0%;
5647
6228
  }
5648
6229
  }
5649
6230
 
5650
6231
  @keyframes pre-wipe-universal {
6232
+ from {
6233
+ background-size: 0% 100%;
6234
+ background-position: left;
6235
+ }
6236
+ to {
6237
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6238
+ background-position: left;
6239
+ }
6240
+ }
6241
+
6242
+ @keyframes pre-wipe-universal-rtl {
6243
+ from {
6244
+ background-size: 0% 100%;
6245
+ background-position: right 0%;
6246
+ }
6247
+ to {
6248
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6249
+ background-position: right 0%;
6250
+ }
6251
+ }
6252
+
6253
+ /* Character-rendered words use a separate moving gradient in front of
6254
+ their solid fill. This makes the individual glyph wipes read as one
6255
+ continuous word-level wipe. */
6256
+ @keyframes char-pre-wipe {
5651
6257
  from {
5652
6258
  background-size:
5653
- 0.75em 100%,
6259
+ var(--wipe-gradient-width, 0.75em) 100%,
5654
6260
  0% 100%;
5655
6261
  background-position:
5656
- -0.75em 0%,
6262
+ calc(-1 * var(--wipe-gradient-width, 0.75em)) 0%,
5657
6263
  left;
5658
6264
  }
5659
6265
  to {
5660
6266
  background-size:
5661
- 0.75em 100%,
6267
+ var(--wipe-gradient-width, 0.75em) 100%,
5662
6268
  0% 100%;
5663
6269
  background-position:
5664
- -0.375em 0%,
6270
+ calc(-1 * var(--wipe-gradient-half, 0.375em)) 0%,
5665
6271
  left;
5666
6272
  }
5667
6273
  }
5668
6274
 
5669
- @keyframes pre-wipe-universal-rtl {
6275
+ @keyframes char-start-wipe {
5670
6276
  from {
5671
6277
  background-size:
5672
- 0.75em 100%,
6278
+ var(--wipe-gradient-width, 0.75em) 100%,
5673
6279
  0% 100%;
5674
6280
  background-position:
5675
- calc(100% + 0.75em) 0%,
5676
- right 0%;
6281
+ calc(-1 * var(--wipe-gradient-width, 0.75em)) 0%,
6282
+ left;
5677
6283
  }
5678
6284
  to {
5679
6285
  background-size:
5680
- 0.75em 100%,
5681
- 0% 100%;
6286
+ var(--wipe-gradient-width, 0.75em) 100%,
6287
+ 100% 100%;
5682
6288
  background-position:
5683
- calc(100% + 0.375em) 0%,
5684
- right 0%;
6289
+ calc(100% + var(--wipe-gradient-half, 0.375em)) 0%,
6290
+ left;
5685
6291
  }
5686
6292
  }
5687
6293
 
5688
- @keyframes pre-wipe-char {
6294
+ @keyframes char-wipe {
5689
6295
  from {
5690
6296
  background-size:
5691
- 0.75em 100%,
6297
+ var(--wipe-gradient-width, 0.75em) 100%,
5692
6298
  0% 100%;
5693
6299
  background-position:
5694
- -0.75em 0%,
6300
+ calc(-1 * var(--wipe-gradient-half, 0.375em)) 0%,
5695
6301
  left;
5696
6302
  }
5697
6303
  to {
5698
6304
  background-size:
5699
- 0.75em 100%,
5700
- 0% 100%;
6305
+ var(--wipe-gradient-width, 0.75em) 100%,
6306
+ 100% 100%;
5701
6307
  background-position:
5702
- -0.375em 0%,
6308
+ calc(100% + var(--wipe-gradient-half, 0.375em)) 0%,
5703
6309
  left;
5704
6310
  }
5705
6311
  }
@@ -5794,6 +6400,15 @@ AmLyrics.styles = i$3 `
5794
6400
  }
5795
6401
  }
5796
6402
 
6403
+ @keyframes drag-char {
6404
+ 0% {
6405
+ transform: translate3d(0, 0, 0);
6406
+ }
6407
+ 100% {
6408
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
6409
+ }
6410
+ }
6411
+
5797
6412
  @keyframes grow-static {
5798
6413
  0%,
5799
6414
  100% {