@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.
package/dist/src/react.js CHANGED
@@ -322,7 +322,7 @@ class GoogleService {
322
322
  }
323
323
  }
324
324
 
325
- const VERSION = '1.5.4';
325
+ const VERSION = '1.5.6';
326
326
  const INSTRUMENTAL_THRESHOLD_MS = 7000; // Show dots for gaps >= 7s
327
327
  const FETCH_TIMEOUT_MS = 8000; // Timeout for all lyrics fetch requests
328
328
  const SEEK_THRESHOLD_MS = 500;
@@ -331,6 +331,15 @@ const GAP_PULSE_DURATION_MS = 4000;
331
331
  const GAP_PULSE_CYCLE_MS = GAP_PULSE_DURATION_MS * 2;
332
332
  const GAP_EXIT_LEAD_MS = 600;
333
333
  const GAP_MIN_SCALE = 0.85;
334
+ const NEXT_WORD_PRE_WIPE_MAX_GAP_MS = 180;
335
+ const NEXT_WORD_PRE_WIPE_MIN_DURATION_MS = 80;
336
+ const NEXT_WORD_PRE_WIPE_MAX_DURATION_MS = 240;
337
+ const BASE_WIPE_GRADIENT_EM = 0.75;
338
+ const LONG_WORD_WIPE_EXTRA_EM = 0.45;
339
+ const LONG_WORD_WIPE_EXTRA_RATIO = 0.35;
340
+ const SHORT_WORD_DRAG_MIN_DURATION_MS = 760;
341
+ const SHORT_WORD_GLOW_MIN_DURATION_MS = 1320;
342
+ const WORD_PRE_WIPE_HANDOFF_LEAD_MS = 100;
334
343
  /**
335
344
  * Fetch with an automatic timeout via AbortSignal.
336
345
  * Rejects if the request takes longer than `timeoutMs`.
@@ -392,6 +401,7 @@ let AmLyrics$1 = class AmLyrics extends i {
392
401
  this.activeLineIds = new Set();
393
402
  this.currentPrimaryActiveLine = null;
394
403
  this.lastPrimaryActiveLine = null;
404
+ this.backgroundExpandedLine = null;
395
405
  // Scroll animation state
396
406
  this.scrollAnimationState = null;
397
407
  this.currentScrollOffset = 0;
@@ -489,11 +499,12 @@ let AmLyrics$1 = class AmLyrics extends i {
489
499
  this.preActiveLineElements = [];
490
500
  this.positionedLineElements = [];
491
501
  this.activeGapLineElements = [];
502
+ this.clearBackgroundExpandedLine();
492
503
  // Stop all running animations and clear highlights immediately
493
504
  if (this.lyricsContainer) {
494
- const activeLines = this.lyricsContainer.querySelectorAll('.lyrics-line.active, .lyrics-line.pre-active, .lyrics-line.bg-expanded');
505
+ const activeLines = this.lyricsContainer.querySelectorAll('.lyrics-line.active, .lyrics-line.pre-active, .lyrics-line.bg-expanded, .lyrics-line.scroll-exiting');
495
506
  activeLines.forEach(line => {
496
- line.classList.remove('active', 'pre-active', 'bg-expanded');
507
+ line.classList.remove('active', 'pre-active', 'bg-expanded', 'scroll-exiting');
497
508
  AmLyrics.resetSyllables(line);
498
509
  });
499
510
  const activeGaps = this.lyricsContainer.querySelectorAll('.lyrics-gap.active, .lyrics-gap.gap-exiting');
@@ -521,7 +532,7 @@ let AmLyrics$1 = class AmLyrics extends i {
521
532
  switchBtn.disabled = this.isFetchingAlternatives;
522
533
  }
523
534
  if (svgEl) {
524
- svgEl.setAttribute('style', `margin-right: 4px; ${this.isFetchingAlternatives ? 'animation: spin 1s linear infinite;' : ''}`);
535
+ svgEl.classList.toggle('is-loading', this.isFetchingAlternatives);
525
536
  }
526
537
  if (labelEl) {
527
538
  labelEl.textContent = this.isFetchingAlternatives
@@ -694,6 +705,7 @@ let AmLyrics$1 = class AmLyrics extends i {
694
705
  this.preActiveLineElements = [];
695
706
  this.positionedLineElements = [];
696
707
  this.activeGapLineElements = [];
708
+ this.clearBackgroundExpandedLine();
697
709
  if (this.lyricsContainer) {
698
710
  this.isProgrammaticScroll = true;
699
711
  this.lyricsContainer.scrollTop = 0;
@@ -761,14 +773,23 @@ let AmLyrics$1 = class AmLyrics extends i {
761
773
  return 21;
762
774
  return 30;
763
775
  }
776
+ static getDisplaySourceLabel(sourceLabel) {
777
+ return sourceLabel.toLowerCase().includes('lyricsplus')
778
+ ? 'QQ'
779
+ : sourceLabel;
780
+ }
781
+ static getSourceKey(sourceLabel) {
782
+ const lower = (sourceLabel || '').trim().toLowerCase();
783
+ if (!lower)
784
+ return '';
785
+ if (lower.includes('lyricsplus') || lower === 'qq')
786
+ return 'qq';
787
+ return lower.replace(/\s+/g, ' ');
788
+ }
764
789
  static mergeAndSortSources(collectedSources) {
765
790
  const uniqueSourcesMap = new Map();
766
791
  for (const source of collectedSources) {
767
- const normalizedSource = source.source
768
- .toLowerCase()
769
- .includes('lyricsplus')
770
- ? 'QQ'
771
- : source.source;
792
+ const normalizedSource = AmLyrics.getDisplaySourceLabel(source.source);
772
793
  if (!uniqueSourcesMap.has(normalizedSource)) {
773
794
  uniqueSourcesMap.set(normalizedSource, {
774
795
  ...source,
@@ -779,9 +800,43 @@ let AmLyrics$1 = class AmLyrics extends i {
779
800
  return Array.from(uniqueSourcesMap.values()).sort((a, b) => AmLyrics.getRankForCollected(a.source, a.lines) -
780
801
  AmLyrics.getRankForCollected(b.source, b.lines));
781
802
  }
803
+ findCurrentSourceIndex(sources = this.availableSources, sourceLabel = this.lyricsSource, lines = this.lyrics) {
804
+ const identityIndex = sources.findIndex(source => source.lines === lines);
805
+ if (identityIndex !== -1)
806
+ return identityIndex;
807
+ const sourceKey = AmLyrics.getSourceKey(sourceLabel);
808
+ if (!sourceKey)
809
+ return -1;
810
+ return sources.findIndex(source => AmLyrics.getSourceKey(source.source) === sourceKey);
811
+ }
812
+ static getNextSourceIndex(sources, currentIndex, currentSourceLabel, currentLines) {
813
+ if (sources.length <= 1)
814
+ return -1;
815
+ if (currentIndex !== -1) {
816
+ return (currentIndex + 1) % sources.length;
817
+ }
818
+ const currentKey = AmLyrics.getSourceKey(currentSourceLabel);
819
+ const fallbackIndex = sources.findIndex(source => source.lines !== currentLines &&
820
+ AmLyrics.getSourceKey(source.source) !== currentKey);
821
+ return fallbackIndex === -1 ? 0 : fallbackIndex;
822
+ }
823
+ async applySourceAtIndex(index) {
824
+ const sourceResult = this.availableSources[index];
825
+ if (!sourceResult)
826
+ return;
827
+ this.currentSourceIndex = index;
828
+ this.lyrics = sourceResult.lines;
829
+ this.lyricsSource = sourceResult.source;
830
+ if (sourceResult.songwriters) {
831
+ this.songwriters = sourceResult.songwriters;
832
+ }
833
+ await this.onLyricsLoaded();
834
+ }
782
835
  async switchSource() {
783
836
  if (this.isFetchingAlternatives)
784
837
  return;
838
+ const currentSourceLabel = this.lyricsSource;
839
+ const currentLines = this.lyrics;
785
840
  if (!this.hasFetchedAllProviders) {
786
841
  this.isFetchingAlternatives = true;
787
842
  this._updateFooter();
@@ -824,10 +879,9 @@ let AmLyrics$1 = class AmLyrics extends i {
824
879
  ...this.availableSources,
825
880
  ...newSources,
826
881
  ]);
827
- // Re-sync current index since sorting might shift elements
828
- this.currentSourceIndex = this.availableSources.findIndex(s => s.source === this.lyricsSource);
829
- if (this.currentSourceIndex === -1)
830
- this.currentSourceIndex = 0;
882
+ // Re-sync current index since sorting or label normalization can
883
+ // shift the currently displayed source underneath the old index.
884
+ this.currentSourceIndex = this.findCurrentSourceIndex(this.availableSources, currentSourceLabel, currentLines);
831
885
  }
832
886
  }
833
887
  }
@@ -838,15 +892,11 @@ let AmLyrics$1 = class AmLyrics extends i {
838
892
  }
839
893
  }
840
894
  if (this.availableSources.length > 1) {
841
- this.currentSourceIndex =
842
- (this.currentSourceIndex + 1) % this.availableSources.length;
843
- const sourceResult = this.availableSources[this.currentSourceIndex];
844
- this.lyrics = sourceResult.lines;
845
- this.lyricsSource = sourceResult.source;
846
- if (sourceResult.songwriters) {
847
- this.songwriters = sourceResult.songwriters;
895
+ const currentIndex = this.findCurrentSourceIndex(this.availableSources, currentSourceLabel, currentLines);
896
+ const nextIndex = AmLyrics.getNextSourceIndex(this.availableSources, currentIndex, currentSourceLabel, currentLines);
897
+ if (nextIndex !== -1) {
898
+ await this.applySourceAtIndex(nextIndex);
848
899
  }
849
- await this.onLyricsLoaded();
850
900
  }
851
901
  }
852
902
  async resolveSongMetadata() {
@@ -1898,13 +1948,15 @@ let AmLyrics$1 = class AmLyrics extends i {
1898
1948
  if (!newActiveLines.includes(lineIndex)) {
1899
1949
  const lineElement = this._getLineElement(lineIndex);
1900
1950
  if (lineElement) {
1901
- if (isSeek || this.isUserScrolling) {
1951
+ if (isSeek ||
1952
+ this.isUserScrolling ||
1953
+ AmLyrics.isLineSyncedLine(this.lyrics?.[lineIndex])) {
1902
1954
  AmLyrics.unfinishSyllables(lineElement);
1903
1955
  }
1904
1956
  else {
1905
1957
  AmLyrics.finishSyllablesUpToTime(lineElement, newTime);
1906
1958
  }
1907
- lineElement.classList.remove('active', 'bg-expanded');
1959
+ lineElement.classList.remove('active', 'bg-expanded', 'scroll-exiting');
1908
1960
  if (lineElement.classList.contains('pre-active')) {
1909
1961
  lineElement.classList.remove('pre-active');
1910
1962
  }
@@ -1914,13 +1966,14 @@ let AmLyrics$1 = class AmLyrics extends i {
1914
1966
  }
1915
1967
  }
1916
1968
  }
1917
- // Add 'active' and 'bg-expanded' to newly active lines
1969
+ // Add 'active' to newly active lines. Background expansion is driven
1970
+ // separately by the current scroll target.
1918
1971
  for (const lineIndex of newActiveLines) {
1919
1972
  if (!oldActiveLines.includes(lineIndex)) {
1920
1973
  const lineElement = this._getLineElement(lineIndex);
1921
1974
  if (lineElement) {
1922
- lineElement.classList.add('active', 'bg-expanded');
1923
- lineElement.classList.remove('pre-active');
1975
+ lineElement.classList.add('active');
1976
+ lineElement.classList.remove('pre-active', 'scroll-exiting');
1924
1977
  const preIdx = this.preActiveLineElements.indexOf(lineElement);
1925
1978
  if (preIdx !== -1)
1926
1979
  this.preActiveLineElements.splice(preIdx, 1);
@@ -2125,7 +2178,11 @@ let AmLyrics$1 = class AmLyrics extends i {
2125
2178
  }
2126
2179
  }
2127
2180
  updated(changedProperties) {
2128
- if (changedProperties.has('lyrics')) {
2181
+ const lyricsDomBecameRenderable = changedProperties.has('lyrics') ||
2182
+ (changedProperties.has('isLoading') &&
2183
+ !this.isLoading &&
2184
+ Boolean(this.lyrics));
2185
+ if (lyricsDomBecameRenderable) {
2129
2186
  this._invalidateCaches();
2130
2187
  this._ensureLineDataCache();
2131
2188
  this._updateCachedIsUnsynced();
@@ -2139,8 +2196,12 @@ let AmLyrics$1 = class AmLyrics extends i {
2139
2196
  for (const lineIndex of activeLines) {
2140
2197
  const lineEl = this._getLineElement(lineIndex);
2141
2198
  if (lineEl)
2142
- lineEl.classList.add('active', 'bg-expanded');
2199
+ lineEl.classList.add('active');
2143
2200
  }
2201
+ const primaryActiveLine = this.getPrimaryActiveLineIndex(activeLines);
2202
+ this.setBackgroundExpandedLine(primaryActiveLine !== null
2203
+ ? this._getLineElement(primaryActiveLine)
2204
+ : null);
2144
2205
  // Trigger a faux time-change so that updateSyllablesForLine fires
2145
2206
  // to setup inline syllable CSS wipe animations for whatever the current time is
2146
2207
  this._onTimeChanged(0, this.currentTime);
@@ -2179,6 +2240,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2179
2240
  this.preActiveLineElements = [];
2180
2241
  this.positionedLineElements = [];
2181
2242
  this.activeGapLineElements = [];
2243
+ this.clearBackgroundExpandedLine();
2182
2244
  this.setUserScrolling(false);
2183
2245
  // Cancel any running animations
2184
2246
  if (this.animationFrameId) {
@@ -2233,6 +2295,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2233
2295
  // Don't override it with a scroll back to the last lyric.
2234
2296
  const footer = this.lyricsContainer.querySelector('.lyrics-footer');
2235
2297
  if (footer?.classList.contains('active')) {
2298
+ this.setBackgroundExpandedLine(null);
2236
2299
  return;
2237
2300
  }
2238
2301
  // 1. Compute scroll lookahead based on gap to next line (YouLyPlus style)
@@ -2273,17 +2336,23 @@ let AmLyrics$1 = class AmLyrics extends i {
2273
2336
  targetElement = this._getLineElement(targetLineIdx);
2274
2337
  }
2275
2338
  }
2276
- if (!targetElement)
2339
+ if (!targetElement) {
2340
+ this.setBackgroundExpandedLine(null);
2277
2341
  return;
2278
- // Unblur the upcoming target line early (pre-active) so background
2279
- // vocals start their max-height/opacity transition in sync with scroll.
2342
+ }
2343
+ const scrollDuration = scrollLookAheadMs;
2344
+ if (targetElement !== this.currentPrimaryActiveLine || forceScroll) {
2345
+ targetElement.style.setProperty('--scroll-duration', `${scrollDuration}ms`);
2346
+ }
2347
+ this.setBackgroundExpandedLine(targetElement);
2348
+ // Unblur the upcoming target line early while the separate bg-expanded
2349
+ // class starts background vocal height/opacity in sync with scroll.
2280
2350
  if (!targetElement.classList.contains('active')) {
2281
2351
  targetElement.classList.add('pre-active');
2282
2352
  if (!this.preActiveLineElements.includes(targetElement)) {
2283
2353
  this.preActiveLineElements.push(targetElement);
2284
2354
  }
2285
2355
  }
2286
- const scrollDuration = scrollLookAheadMs;
2287
2356
  this.focusLine(targetElement, forceScroll, scrollDuration);
2288
2357
  }
2289
2358
  _getTextWidth(text, font) {
@@ -2356,6 +2425,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2356
2425
  this.preActiveLineElements = [];
2357
2426
  this.positionedLineElements = [];
2358
2427
  this.activeGapLineElements = [];
2428
+ this.clearBackgroundExpandedLine();
2359
2429
  this.visibilityObserver?.disconnect();
2360
2430
  this.visibilityObserver = undefined;
2361
2431
  }
@@ -2391,6 +2461,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2391
2461
  const groupGrowable = new Array(wordGroups.length).fill(false);
2392
2462
  const groupGlowing = new Array(wordGroups.length).fill(false);
2393
2463
  const groupCharRise = new Array(wordGroups.length).fill(false);
2464
+ const groupCharDrag = new Array(wordGroups.length).fill(false);
2394
2465
  const vwFullText = new Array(wordGroups.length).fill('');
2395
2466
  const vwFullDuration = new Array(wordGroups.length).fill(0);
2396
2467
  const vwCharOffset = new Array(wordGroups.length).fill(0);
@@ -2426,28 +2497,42 @@ let AmLyrics$1 = class AmLyrics extends i {
2426
2497
  const isLineSynced = line.isWordSynced === false || line.text.some(s => s.lineSynced);
2427
2498
  let isGrowableVW = canAnimateByChar && wordLen > 0 && wordLen <= 7;
2428
2499
  if (isGrowableVW) {
2429
- if (wordLen < 3) {
2500
+ if (wordLen <= 1) {
2430
2501
  isGrowableVW =
2431
2502
  combinedDuration >= 1050 && combinedDuration >= wordLen * 525;
2432
2503
  }
2504
+ else if (wordLen <= 3) {
2505
+ isGrowableVW =
2506
+ combinedDuration >=
2507
+ SHORT_WORD_GLOW_MIN_DURATION_MS + (wordLen - 2) * 140;
2508
+ }
2433
2509
  else {
2434
2510
  isGrowableVW =
2435
2511
  combinedDuration >= 850 && combinedDuration >= wordLen * 190;
2436
2512
  }
2437
2513
  }
2438
2514
  const hasCharRiseDuration = combinedDuration >= Math.max(700, wordLen * 85);
2515
+ const hasTinyWordDragDuration = wordLen >= 2 &&
2516
+ wordLen <= 3 &&
2517
+ combinedDuration >=
2518
+ Math.max(SHORT_WORD_DRAG_MIN_DURATION_MS, wordLen * 150);
2439
2519
  const hasLongShortWordDuration = wordLen >= 4 && combinedDuration >= Math.max(1300, wordLen * 260);
2440
2520
  const isCharRiseVW = canAnimateByChar &&
2441
2521
  !isLineSynced &&
2442
2522
  !isGrowableVW &&
2443
2523
  ((wordLen >= 8 && hasCharRiseDuration) ||
2444
2524
  (wordLen < 8 && hasLongShortWordDuration));
2525
+ const isCharDragVW = canAnimateByChar &&
2526
+ !isLineSynced &&
2527
+ !isGrowableVW &&
2528
+ hasTinyWordDragDuration;
2445
2529
  const isGlowingVW = isGrowableVW && !isLineSynced;
2446
2530
  let charOff = 0;
2447
2531
  for (let gi = vwStart; gi <= vwEnd; gi += 1) {
2448
2532
  groupGrowable[gi] = isGrowableVW;
2449
2533
  groupGlowing[gi] = isGlowingVW;
2450
2534
  groupCharRise[gi] = isCharRiseVW;
2535
+ groupCharDrag[gi] = isCharDragVW;
2451
2536
  vwFullText[gi] = combinedText;
2452
2537
  vwFullDuration[gi] = combinedDuration;
2453
2538
  vwCharOffset[gi] = charOff;
@@ -2463,6 +2548,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2463
2548
  groupGrowable,
2464
2549
  groupGlowing,
2465
2550
  groupCharRise,
2551
+ groupCharDrag,
2466
2552
  vwFullText,
2467
2553
  vwFullDuration,
2468
2554
  vwCharOffset,
@@ -2482,48 +2568,106 @@ let AmLyrics$1 = class AmLyrics extends i {
2482
2568
  return;
2483
2569
  const computedStyle = getComputedStyle(referenceSyllable);
2484
2570
  const { font } = computedStyle; // Full font string
2485
- const fontSize = parseFloat(computedStyle.fontSize);
2486
- const charTimedWords = this.shadowRoot.querySelectorAll('.lyrics-word.growable, .lyrics-word.char-rise');
2487
- if (!charTimedWords)
2571
+ const fontSize = Number.parseFloat(computedStyle.fontSize) || 16;
2572
+ const charTimedWords = Array.from(this.shadowRoot.querySelectorAll('.lyrics-word.growable, .lyrics-word.char-rise, .lyrics-word.char-drag'));
2573
+ if (charTimedWords.length === 0)
2488
2574
  return;
2489
- charTimedWords.forEach((wordSpan) => {
2490
- const syllableWraps = wordSpan.querySelectorAll('.lyrics-syllable-wrap');
2491
- // Flatten syllables
2575
+ const wordsByVirtualId = new Map();
2576
+ charTimedWords.forEach((wordSpan, index) => {
2577
+ const virtualWordId = wordSpan.dataset.virtualWordId || `word-${index}`;
2578
+ const words = wordsByVirtualId.get(virtualWordId);
2579
+ if (words) {
2580
+ words.push(wordSpan);
2581
+ }
2582
+ else {
2583
+ wordsByVirtualId.set(virtualWordId, [wordSpan]);
2584
+ }
2585
+ });
2586
+ wordsByVirtualId.forEach(wordSpans => {
2492
2587
  const syllables = [];
2493
- syllableWraps.forEach((wrap) => {
2494
- const syl = wrap.querySelector('.lyrics-syllable');
2495
- if (syl)
2496
- syllables.push(syl);
2588
+ wordSpans.forEach(wordSpan => {
2589
+ const syllableWraps = wordSpan.querySelectorAll('.lyrics-syllable-wrap');
2590
+ syllableWraps.forEach(wrap => {
2591
+ const syl = wrap.querySelector('.lyrics-syllable');
2592
+ if (syl)
2593
+ syllables.push(syl);
2594
+ });
2497
2595
  });
2498
- syllables.forEach(sylSpan => {
2499
- const charSpans = sylSpan.querySelectorAll('.char');
2500
- if (charSpans.length === 0)
2501
- return;
2502
- // Logic from YouLyPlus renderCharWipes:
2503
- // Use textContent from spans to ensure we measure what is rendered
2504
- const chars = Array.from(charSpans).map(span => span.textContent || '');
2505
- const charWidths = chars.map(c => this._getTextWidth(c, font));
2506
- const totalSyllableWidth = charWidths.reduce((a, b) => a + b, 0);
2507
- const duration = parseFloat(sylSpan.dataset.duration || '0');
2508
- const velocityPxPerMs = duration > 0 ? totalSyllableWidth / duration : 0;
2509
- // Gradient width in pixels = 0.375 * fontSize
2510
- // This matches YouLyPlus visual gradient size
2511
- const gradientWidthPx = 0.375 * fontSize;
2512
- const gradientDurationMs = velocityPxPerMs > 0 ? gradientWidthPx / velocityPxPerMs : 100;
2513
- let cumulativeCharWidth = 0;
2514
- charSpans.forEach((spanArg, i) => {
2515
- const charWidth = charWidths[i];
2516
- const span = spanArg;
2517
- if (totalSyllableWidth > 0) {
2518
- const startPercent = cumulativeCharWidth / totalSyllableWidth;
2519
- const durationPercent = charWidth / totalSyllableWidth;
2520
- span.dataset.wipeStart = startPercent.toFixed(4);
2521
- span.dataset.wipeDuration = durationPercent.toFixed(4);
2522
- // The critical missing piece:
2523
- span.dataset.preWipeArrival = (duration * startPercent).toFixed(2);
2524
- span.dataset.preWipeDuration = gradientDurationMs.toFixed(2);
2596
+ const charSpans = syllables.flatMap(syl => {
2597
+ const spans = Array.from(syl.querySelectorAll('.char'));
2598
+ const target = syl;
2599
+ target._cachedCharSpans = spans;
2600
+ return spans;
2601
+ });
2602
+ if (charSpans.length === 0)
2603
+ return;
2604
+ wordSpans.forEach(wordSpan => {
2605
+ const target = wordSpan;
2606
+ target._cachedVirtualWordElements = wordSpans;
2607
+ target._cachedVirtualWordCharSpans = charSpans;
2608
+ });
2609
+ const syllableEntries = syllables.map(syl => {
2610
+ const spans = syl._cachedCharSpans;
2611
+ const charWidths = spans.map(span => this._getTextWidth(span.textContent || '', font));
2612
+ const totalWidth = charWidths.reduce((a, b) => a + b, 0);
2613
+ return {
2614
+ syl,
2615
+ spans,
2616
+ charWidths,
2617
+ totalWidth,
2618
+ start: parseFloat(syl.getAttribute('data-start-time') || ''),
2619
+ end: parseFloat(syl.getAttribute('data-end-time') || ''),
2620
+ };
2621
+ });
2622
+ const totalWordWidth = syllableEntries.reduce((total, entry) => total + entry.totalWidth, 0);
2623
+ if (totalWordWidth <= 0)
2624
+ return;
2625
+ const virtualWordStart = Math.min(...syllableEntries
2626
+ .map(entry => entry.start)
2627
+ .filter(start => Number.isFinite(start)));
2628
+ const virtualWordEnd = Math.max(...syllableEntries
2629
+ .map(entry => entry.end)
2630
+ .filter(end => Number.isFinite(end)));
2631
+ const virtualWordDuration = virtualWordEnd - virtualWordStart;
2632
+ const hasTimedSyllables = Number.isFinite(virtualWordStart) &&
2633
+ Number.isFinite(virtualWordEnd) &&
2634
+ virtualWordDuration > 0;
2635
+ const wordVelocityPxPerMs = hasTimedSyllables
2636
+ ? totalWordWidth / virtualWordDuration
2637
+ : 0;
2638
+ const gradientLeadWidthPx = (BASE_WIPE_GRADIENT_EM * Math.max(1, fontSize)) / 2;
2639
+ const measuredPreWipeDuration = wordVelocityPxPerMs > 0
2640
+ ? gradientLeadWidthPx / wordVelocityPxPerMs
2641
+ : 100;
2642
+ let cumulativeCharWidth = 0;
2643
+ syllableEntries.forEach(entry => {
2644
+ let cumulativeSyllableWidth = 0;
2645
+ const syllableDuration = entry.end - entry.start;
2646
+ const useSyllableTiming = hasTimedSyllables &&
2647
+ Number.isFinite(entry.start) &&
2648
+ Number.isFinite(entry.end) &&
2649
+ syllableDuration > 0 &&
2650
+ entry.totalWidth > 0;
2651
+ entry.spans.forEach((span, index) => {
2652
+ const charWidth = entry.charWidths[index];
2653
+ let startPercent = cumulativeCharWidth / totalWordWidth;
2654
+ let durationPercent = charWidth / totalWordWidth;
2655
+ if (useSyllableTiming) {
2656
+ const charStartMs = entry.start -
2657
+ virtualWordStart +
2658
+ (cumulativeSyllableWidth / entry.totalWidth) * syllableDuration;
2659
+ const charDurationMs = (charWidth / entry.totalWidth) * syllableDuration;
2660
+ startPercent = AmLyrics.clamp(charStartMs / virtualWordDuration, 0, 1);
2661
+ durationPercent = AmLyrics.clamp(charDurationMs / virtualWordDuration, 0, 1);
2525
2662
  }
2663
+ const target = span;
2664
+ target.dataset.wipeStart = startPercent.toFixed(4);
2665
+ target.dataset.wipeDuration = durationPercent.toFixed(4);
2666
+ target.dataset.preWipeDuration = measuredPreWipeDuration.toFixed(2);
2667
+ target.style.setProperty('--word-wipe-width', `${totalWordWidth}px`);
2668
+ target.style.setProperty('--char-wipe-position', `${-cumulativeCharWidth}px`);
2526
2669
  cumulativeCharWidth += charWidth;
2670
+ cumulativeSyllableWidth += charWidth;
2527
2671
  });
2528
2672
  });
2529
2673
  });
@@ -2531,6 +2675,33 @@ let AmLyrics$1 = class AmLyrics extends i {
2531
2675
  static arraysEqual(a, b) {
2532
2676
  return a.length === b.length && a.every((val, i) => val === b[i]);
2533
2677
  }
2678
+ static isLineSyncedLine(line) {
2679
+ if (!line)
2680
+ return false;
2681
+ return line.isWordSynced === false || line.text.some(s => s.lineSynced);
2682
+ }
2683
+ getLineHighlightEndTime(index) {
2684
+ if (!this.lyrics)
2685
+ return 0;
2686
+ const line = this.lyrics[index];
2687
+ if (!line)
2688
+ return 0;
2689
+ const rawEnd = Math.max(line.endtime, line.timestamp);
2690
+ const nextLine = this.lyrics[index + 1];
2691
+ if (!nextLine || nextLine.timestamp <= line.timestamp) {
2692
+ return rawEnd > line.timestamp ? rawEnd + 200 : rawEnd;
2693
+ }
2694
+ if (rawEnd > line.timestamp) {
2695
+ if (nextLine.timestamp < rawEnd) {
2696
+ return rawEnd;
2697
+ }
2698
+ const gapToNext = nextLine.timestamp - rawEnd;
2699
+ if (gapToNext >= INSTRUMENTAL_THRESHOLD_MS) {
2700
+ return rawEnd;
2701
+ }
2702
+ }
2703
+ return nextLine.timestamp;
2704
+ }
2534
2705
  static getLineIndexFromElement(lineElement) {
2535
2706
  if (!lineElement)
2536
2707
  return null;
@@ -2561,6 +2732,26 @@ let AmLyrics$1 = class AmLyrics extends i {
2561
2732
  }
2562
2733
  this.preActiveLineElements = keptLines;
2563
2734
  }
2735
+ setBackgroundExpandedLine(lineElement) {
2736
+ const target = lineElement &&
2737
+ !lineElement.classList.contains('lyrics-gap') &&
2738
+ lineElement.querySelector('.background-vocal-container')
2739
+ ? lineElement
2740
+ : null;
2741
+ if (this.backgroundExpandedLine === target) {
2742
+ if (target && !target.classList.contains('bg-expanded')) {
2743
+ target.classList.add('bg-expanded');
2744
+ }
2745
+ return;
2746
+ }
2747
+ this.backgroundExpandedLine?.classList.remove('bg-expanded');
2748
+ this.backgroundExpandedLine = target;
2749
+ target?.classList.add('bg-expanded');
2750
+ }
2751
+ clearBackgroundExpandedLine() {
2752
+ this.backgroundExpandedLine?.classList.remove('bg-expanded');
2753
+ this.backgroundExpandedLine = null;
2754
+ }
2564
2755
  getPrimaryActiveLineIndex(activeIndices) {
2565
2756
  if (activeIndices.length === 0)
2566
2757
  return null;
@@ -2637,7 +2828,12 @@ let AmLyrics$1 = class AmLyrics extends i {
2637
2828
  // effectiveEndTimes). Lines stay active until their extended end,
2638
2829
  // so we no longer need to remove .active here.
2639
2830
  this.lastPrimaryActiveLine = this.currentPrimaryActiveLine;
2831
+ if (this.lastPrimaryActiveLine) {
2832
+ this.lastPrimaryActiveLine.style.setProperty('--scroll-duration', `${scrollDuration ?? SCROLL_ANIMATION_DURATION_MS}ms`);
2833
+ this.lastPrimaryActiveLine.classList.add('scroll-exiting');
2834
+ }
2640
2835
  this.currentPrimaryActiveLine = lineElement;
2836
+ this.currentPrimaryActiveLine.classList.remove('scroll-exiting');
2641
2837
  const lineIndex = AmLyrics.getLineIndexFromElement(lineElement);
2642
2838
  if (lineIndex !== null) {
2643
2839
  this.lastActiveIndex = lineIndex;
@@ -2750,9 +2946,10 @@ let AmLyrics$1 = class AmLyrics extends i {
2750
2946
  const activeLines = [];
2751
2947
  for (let i = 0; i < this.lyrics.length; i += 1) {
2752
2948
  const line = this.lyrics[i];
2949
+ const highlightEndTime = this.getLineHighlightEndTime(i);
2753
2950
  if (line.timestamp > time)
2754
2951
  break;
2755
- if (time >= line.timestamp && time < line.endtime) {
2952
+ if (time >= line.timestamp && time < highlightEndTime) {
2756
2953
  activeLines.push(i);
2757
2954
  }
2758
2955
  }
@@ -2949,7 +3146,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2949
3146
  allLines.forEach(lineEl => {
2950
3147
  AmLyrics.resetSyllables(lineEl);
2951
3148
  // Remove scroll-animate class and properties to stop any scroll animations
2952
- lineEl.classList.remove('scroll-animate');
3149
+ lineEl.classList.remove('scroll-animate', 'scroll-exiting');
2953
3150
  lineEl.style.removeProperty('--scroll-delta');
2954
3151
  lineEl.style.removeProperty('--lyrics-line-delay');
2955
3152
  });
@@ -2977,6 +3174,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2977
3174
  this.lastPrimaryActiveLine = null;
2978
3175
  this.activeLineIds.clear();
2979
3176
  this.animatingLines = [];
3177
+ this.setBackgroundExpandedLine(null);
2980
3178
  // Find the clicked line element and scroll to it with forceScroll (like YouLyPlus)
2981
3179
  // Timestamps are already in milliseconds — match the data-start-time attribute directly
2982
3180
  const clickedLineElement = this.lyricsContainer?.querySelector(`.lyrics-line[data-start-time="${line.text[0]?.timestamp || 0}"]`);
@@ -2993,6 +3191,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2993
3191
  this.isClickSeeking = false;
2994
3192
  }, 800);
2995
3193
  this.scrollToActiveLineYouLy(clickedLineElement, true);
3194
+ this.setBackgroundExpandedLine(clickedLineElement);
2996
3195
  }
2997
3196
  const event = new CustomEvent('line-click', {
2998
3197
  detail: {
@@ -3170,26 +3369,30 @@ let AmLyrics$1 = class AmLyrics extends i {
3170
3369
  return;
3171
3370
  const duration = Math.min(450, scrollDuration ?? SCROLL_ANIMATION_DURATION_MS);
3172
3371
  const delayIncrement = duration * 0.1;
3372
+ const maxStaggerSteps = 4;
3173
3373
  const lookAhead = 20;
3174
3374
  const len = lineArray.length;
3175
3375
  const start = Math.max(0, referenceIndex - lookAhead);
3176
3376
  const end = Math.min(len, referenceIndex + lookAhead);
3177
3377
  let maxAnimationDuration = 0;
3178
3378
  const newAnimatingLines = [];
3379
+ const lineDelays = new Map();
3179
3380
  const scrollingDown = delta >= 0;
3180
3381
  if (scrollingDown) {
3181
3382
  let delayCounter = 0;
3182
3383
  for (let i = start; i < end; i += 1) {
3183
3384
  const line = lineArray[i];
3184
- const delay = i >= referenceIndex ? delayCounter * delayIncrement : 0;
3385
+ const delay = i >= referenceIndex
3386
+ ? Math.min(delayCounter, maxStaggerSteps) * delayIncrement
3387
+ : 0;
3185
3388
  if (i >= referenceIndex && !line.classList.contains('lyrics-gap')) {
3186
3389
  delayCounter += 1;
3187
3390
  }
3188
3391
  line.style.setProperty('--scroll-delta', `${delta}px`);
3189
3392
  line.style.setProperty('--lyrics-line-delay', `${delay}ms`);
3190
- line.style.setProperty('--scroll-duration', `${duration + 100}ms`);
3393
+ lineDelays.set(line, delay);
3191
3394
  newAnimatingLines.push(line);
3192
- const lineDuration = duration + delay;
3395
+ const lineDuration = duration + 100 + delay;
3193
3396
  if (lineDuration > maxAnimationDuration) {
3194
3397
  maxAnimationDuration = lineDuration;
3195
3398
  }
@@ -3199,20 +3402,28 @@ let AmLyrics$1 = class AmLyrics extends i {
3199
3402
  let delayCounter = 0;
3200
3403
  for (let i = end - 1; i >= start; i -= 1) {
3201
3404
  const line = lineArray[i];
3202
- const delay = i <= referenceIndex ? delayCounter * delayIncrement : 0;
3405
+ const delay = i <= referenceIndex
3406
+ ? Math.min(delayCounter, maxStaggerSteps) * delayIncrement
3407
+ : 0;
3203
3408
  if (i <= referenceIndex && !line.classList.contains('lyrics-gap')) {
3204
3409
  delayCounter += 1;
3205
3410
  }
3206
3411
  line.style.setProperty('--scroll-delta', `${delta}px`);
3207
3412
  line.style.setProperty('--lyrics-line-delay', `${delay}ms`);
3208
- line.style.setProperty('--scroll-duration', `${duration + 100}ms`);
3413
+ lineDelays.set(line, delay);
3209
3414
  newAnimatingLines.push(line);
3210
- const lineDuration = duration + delay;
3415
+ const lineDuration = duration + 100 + delay;
3211
3416
  if (lineDuration > maxAnimationDuration) {
3212
3417
  maxAnimationDuration = lineDuration;
3213
3418
  }
3214
3419
  }
3215
3420
  }
3421
+ /* Preserve the staggered starts, but make every line settle together.
3422
+ This keeps the selected line from drifting after its neighbours. */
3423
+ for (const line of newAnimatingLines) {
3424
+ const delay = lineDelays.get(line) ?? 0;
3425
+ line.style.setProperty('--scroll-duration', `${Math.max(100, maxAnimationDuration - delay)}ms`);
3426
+ }
3216
3427
  // --- Step 3: Force reflow so the browser sees the class removal ---
3217
3428
  // Use offsetHeight which is cheaper than getBoundingClientRect
3218
3429
  // eslint-disable-next-line no-void
@@ -3338,30 +3549,255 @@ let AmLyrics$1 = class AmLyrics extends i {
3338
3549
  }
3339
3550
  /**
3340
3551
  * Update syllable highlight animation - apply CSS wipe animation
3341
- * (Exact copy from YouLyPlus _updateSyllableAnimation)
3342
3552
  */
3553
+ static clamp(value, min, max) {
3554
+ return Math.min(max, Math.max(min, value));
3555
+ }
3556
+ static getVisibleCharacterCount(element) {
3557
+ const attrLength = parseFloat(element.getAttribute('data-word-length') || '');
3558
+ if (Number.isFinite(attrLength) && attrLength > 0)
3559
+ return attrLength;
3560
+ return (element.textContent || '').replace(/\s/g, '').length;
3561
+ }
3562
+ static getLongWordWipeScale(charCount) {
3563
+ if (charCount <= 6)
3564
+ return 1;
3565
+ return (1 +
3566
+ AmLyrics.clamp((charCount - 6) / 10, 0, 1) * LONG_WORD_WIPE_EXTRA_RATIO);
3567
+ }
3568
+ static applyWipeShape(element, charCount) {
3569
+ const extra = AmLyrics.clamp((charCount - 6) / 10, 0, 1) * LONG_WORD_WIPE_EXTRA_EM;
3570
+ const width = BASE_WIPE_GRADIENT_EM + extra;
3571
+ element.style.setProperty('--wipe-gradient-width', `${width.toFixed(3)}em`);
3572
+ element.style.setProperty('--wipe-gradient-half', `${(width / 2).toFixed(3)}em`);
3573
+ }
3574
+ static ensureWordWipeGeometry(charSpans, charCount) {
3575
+ if (charSpans.length === 0)
3576
+ return;
3577
+ const approxWidthCh = Math.max(1, charCount || charSpans.length);
3578
+ charSpans.forEach((span, index) => {
3579
+ if (!span.style.getPropertyValue('--word-wipe-width')) {
3580
+ span.style.setProperty('--word-wipe-width', `${approxWidthCh}ch`);
3581
+ }
3582
+ if (!span.style.getPropertyValue('--char-wipe-position')) {
3583
+ const startPct = Number.parseFloat(span.dataset.wipeStart || `${index / Math.max(1, charSpans.length)}`);
3584
+ span.style.setProperty('--char-wipe-position', `${-(AmLyrics.clamp(startPct, 0, 1) * approxWidthCh)}ch`);
3585
+ }
3586
+ });
3587
+ }
3588
+ static clearPreHighlight(syllable) {
3589
+ const target = syllable;
3590
+ target.classList.remove('pre-highlight');
3591
+ target.style.removeProperty('--pre-wipe-duration');
3592
+ target.style.removeProperty('--pre-wipe-delay');
3593
+ target.style.animation = '';
3594
+ target
3595
+ .querySelectorAll('.pre-wipe-lead')
3596
+ .forEach(element => AmLyrics.clearPreWipeLead(element));
3597
+ }
3598
+ static clearPreWipeLead(element) {
3599
+ element.classList.remove('pre-wipe-lead');
3600
+ element.style.removeProperty('--pre-wipe-duration');
3601
+ element.style.removeProperty('--pre-wipe-delay');
3602
+ }
3603
+ static hasTextBoundaryAfter(syllable) {
3604
+ return /\s$/.test(syllable.textContent || '');
3605
+ }
3606
+ static getSyllableWordIndex(syllable) {
3607
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3608
+ const virtualWordId = wordElement?.dataset.virtualWordId;
3609
+ if (virtualWordId) {
3610
+ return `virtual:${virtualWordId}`;
3611
+ }
3612
+ const virtualWordStart = wordElement?.dataset.virtualWordStart;
3613
+ const virtualWordEnd = wordElement?.dataset.virtualWordEnd;
3614
+ if (virtualWordStart || virtualWordEnd) {
3615
+ return `virtual:${virtualWordStart || ''}:${virtualWordEnd || ''}`;
3616
+ }
3617
+ return (syllable.getAttribute('data-word-index') ||
3618
+ syllable.getAttribute('data-syllable-index') ||
3619
+ '');
3620
+ }
3621
+ static getNextWordSyllable(syllables, index) {
3622
+ const current = syllables[index];
3623
+ const currentWordIndex = AmLyrics.getSyllableWordIndex(current);
3624
+ const previousSyllable = current;
3625
+ for (let i = index + 1; i < syllables.length; i += 1) {
3626
+ const candidate = syllables[i];
3627
+ if (candidate.classList.contains('transliteration')) {
3628
+ // eslint-disable-next-line no-continue
3629
+ continue;
3630
+ }
3631
+ const candidateWordIndex = AmLyrics.getSyllableWordIndex(candidate);
3632
+ if (candidateWordIndex === currentWordIndex ||
3633
+ !AmLyrics.hasTextBoundaryAfter(previousSyllable)) {
3634
+ return null;
3635
+ }
3636
+ return candidate;
3637
+ }
3638
+ return null;
3639
+ }
3640
+ static getPreviousNonTransliterationSyllable(syllables, index) {
3641
+ for (let i = index - 1; i >= 0; i -= 1) {
3642
+ const candidate = syllables[i];
3643
+ if (!candidate.classList.contains('transliteration')) {
3644
+ return candidate;
3645
+ }
3646
+ }
3647
+ return null;
3648
+ }
3649
+ static getRenderedWordSyllables(syllable) {
3650
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3651
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
3652
+ const wordSyllables = wordElements.flatMap(element => Array.from(element.querySelectorAll('.lyrics-syllable')));
3653
+ return wordSyllables.filter(wordSyllable => !wordSyllable.classList.contains('transliteration'));
3654
+ }
3655
+ static getWordElementForSyllable(syllable) {
3656
+ return syllable.parentElement?.parentElement;
3657
+ }
3658
+ static getWordPreWipeKey(syllable) {
3659
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3660
+ return (wordElement?.dataset.virtualWordId ||
3661
+ `${syllable.getAttribute('data-start-time') || ''}:${AmLyrics.getSyllableWordIndex(syllable)}`);
3662
+ }
3663
+ static isPreWipeArmed(syllable) {
3664
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3665
+ const target = wordElement;
3666
+ return Boolean(target?._wordPreWipeKey === AmLyrics.getWordPreWipeKey(syllable));
3667
+ }
3668
+ static applyWordPreWipe(nextSyllable, wordSyllables, currentTimeMs, preWipeStartMs, preWipeDurationMs) {
3669
+ if (AmLyrics.isPreWipeArmed(nextSyllable))
3670
+ return;
3671
+ const wordElement = AmLyrics.getWordElementForSyllable(nextSyllable);
3672
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
3673
+ const charSpans = AmLyrics.getCachedVirtualWordCharSpans(wordElement, []);
3674
+ const elapsedPreWipe = currentTimeMs - preWipeStartMs;
3675
+ const charCount = charSpans.length ||
3676
+ wordSyllables.reduce((total, wordSyllable) => total + AmLyrics.getVisibleCharacterCount(wordSyllable), 0) ||
3677
+ AmLyrics.getVisibleCharacterCount(nextSyllable);
3678
+ AmLyrics.ensureWordWipeGeometry(charSpans, charCount);
3679
+ const leadChar = charSpans[0];
3680
+ const leadCharSyllable = leadChar?.closest('.lyrics-syllable');
3681
+ const preWipeSyllable = leadCharSyllable || wordSyllables[0] || nextSyllable;
3682
+ AmLyrics.applyWipeShape(preWipeSyllable, charCount);
3683
+ preWipeSyllable.style.setProperty('--pre-wipe-duration', `${preWipeDurationMs}ms`);
3684
+ preWipeSyllable.style.setProperty('--pre-wipe-delay', `${-elapsedPreWipe}ms`);
3685
+ preWipeSyllable.classList.add('pre-highlight');
3686
+ // Character-animated words still need a single word-leading gradient.
3687
+ // Giving every glyph this state creates one gradient per character and
3688
+ // makes the whole word enter the pre-wipe continuation simultaneously.
3689
+ if (leadChar) {
3690
+ AmLyrics.applyWipeShape(leadChar, charCount);
3691
+ leadChar.style.setProperty('--pre-wipe-duration', `${preWipeDurationMs}ms`);
3692
+ leadChar.style.setProperty('--pre-wipe-delay', `${-elapsedPreWipe}ms`);
3693
+ leadChar.classList.add('pre-wipe-lead');
3694
+ }
3695
+ wordElements.forEach(element => {
3696
+ const target = element;
3697
+ target._wordPreWipeKey = AmLyrics.getWordPreWipeKey(nextSyllable);
3698
+ });
3699
+ }
3700
+ static maybePreWipeNextWord(syllables, index, currentTimeMs, currentEndTimeMs) {
3701
+ const syllable = syllables[index];
3702
+ if (syllable.classList.contains('line-synced') ||
3703
+ syllable.classList.contains('transliteration') ||
3704
+ syllable.closest('.lyrics-gap')) {
3705
+ return;
3706
+ }
3707
+ const currentWordReady = syllable.classList.contains('finished') ||
3708
+ currentTimeMs >= currentEndTimeMs - WORD_PRE_WIPE_HANDOFF_LEAD_MS;
3709
+ if (!currentWordReady) {
3710
+ return;
3711
+ }
3712
+ const nextSyllable = AmLyrics.getNextWordSyllable(syllables, index);
3713
+ if (!nextSyllable ||
3714
+ nextSyllable.classList.contains('line-synced') ||
3715
+ nextSyllable.classList.contains('transliteration') ||
3716
+ nextSyllable.closest('.lyrics-gap') ||
3717
+ nextSyllable.classList.contains('highlight') ||
3718
+ nextSyllable.classList.contains('finished')) {
3719
+ return;
3720
+ }
3721
+ const nextStartTimeMs = nextSyllable._cachedStartTime;
3722
+ if (!Number.isFinite(nextStartTimeMs))
3723
+ return;
3724
+ const gapMs = nextStartTimeMs - currentEndTimeMs;
3725
+ if (gapMs > NEXT_WORD_PRE_WIPE_MAX_GAP_MS || gapMs < -50) {
3726
+ return;
3727
+ }
3728
+ const nextWordSyllables = AmLyrics.getRenderedWordSyllables(nextSyllable);
3729
+ const preWipeSyllables = nextWordSyllables.length > 0 ? nextWordSyllables : [nextSyllable];
3730
+ const wordElement = AmLyrics.getWordElementForSyllable(nextSyllable);
3731
+ const wordCharSpans = AmLyrics.getCachedVirtualWordCharSpans(wordElement, []);
3732
+ const charCount = wordCharSpans.length ||
3733
+ preWipeSyllables.reduce((total, wordSyllable) => total + AmLyrics.getVisibleCharacterCount(wordSyllable), 0);
3734
+ if (charCount <= 0)
3735
+ return;
3736
+ const preWipeDuration = AmLyrics.clamp(64 + charCount * 9, NEXT_WORD_PRE_WIPE_MIN_DURATION_MS, NEXT_WORD_PRE_WIPE_MAX_DURATION_MS);
3737
+ const preWipeStart = Math.max(nextStartTimeMs - preWipeDuration, currentEndTimeMs - WORD_PRE_WIPE_HANDOFF_LEAD_MS);
3738
+ if (currentTimeMs < preWipeStart || currentTimeMs >= nextStartTimeMs) {
3739
+ return;
3740
+ }
3741
+ AmLyrics.applyWordPreWipe(nextSyllable, preWipeSyllables, currentTimeMs, preWipeStart, preWipeDuration);
3742
+ }
3743
+ static getCachedCharSpans(element) {
3744
+ const cacheTarget = element;
3745
+ if (!cacheTarget._cachedCharSpans) {
3746
+ cacheTarget._cachedCharSpans = Array.from(element.querySelectorAll('span.char'));
3747
+ }
3748
+ return cacheTarget._cachedCharSpans;
3749
+ }
3750
+ static getCachedVirtualWordElements(wordElement) {
3751
+ if (!wordElement)
3752
+ return [];
3753
+ const cacheTarget = wordElement;
3754
+ if (cacheTarget._cachedVirtualWordElements) {
3755
+ return cacheTarget._cachedVirtualWordElements;
3756
+ }
3757
+ const { virtualWordId } = wordElement.dataset;
3758
+ let wordElements = [wordElement];
3759
+ if (virtualWordId && wordElement.parentElement) {
3760
+ wordElements = Array.from(wordElement.parentElement.querySelectorAll('.lyrics-word')).filter(el => el.dataset.virtualWordId === virtualWordId);
3761
+ }
3762
+ wordElements.forEach(element => {
3763
+ const target = element;
3764
+ target._cachedVirtualWordElements = wordElements;
3765
+ });
3766
+ return wordElements;
3767
+ }
3768
+ static getCachedVirtualWordCharSpans(wordElement, fallbackCharSpans) {
3769
+ if (!wordElement)
3770
+ return fallbackCharSpans;
3771
+ const cacheTarget = wordElement;
3772
+ if (cacheTarget._cachedVirtualWordCharSpans) {
3773
+ return cacheTarget._cachedVirtualWordCharSpans;
3774
+ }
3775
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
3776
+ const charSpans = wordElements.flatMap(word => Array.from(word.querySelectorAll('span.char')));
3777
+ const result = charSpans.length > 0 ? charSpans : fallbackCharSpans;
3778
+ wordElements.forEach(element => {
3779
+ const target = element;
3780
+ target._cachedVirtualWordCharSpans = result;
3781
+ });
3782
+ return result;
3783
+ }
3343
3784
  static updateSyllableAnimation(syllable, elapsedTimeMs = 0) {
3344
3785
  if (syllable.classList.contains('highlight'))
3345
3786
  return;
3346
3787
  const { classList } = syllable;
3788
+ const hadPreHighlight = classList.contains('pre-highlight');
3347
3789
  const isRTL = classList.contains('rtl-text');
3348
- const charSpans = Array.from(syllable.querySelectorAll('span.char'));
3790
+ const charSpans = AmLyrics.getCachedCharSpans(syllable);
3349
3791
  const wordElement = syllable.parentElement?.parentElement; // syllable-wrap -> word
3350
- const virtualWordId = wordElement?.dataset
3351
- .virtualWordId;
3352
- let wordElements = [];
3353
- if (virtualWordId && wordElement?.parentElement) {
3354
- wordElements = Array.from(wordElement.parentElement.querySelectorAll('.lyrics-word')).filter(el => el.dataset.virtualWordId === virtualWordId);
3355
- }
3356
- else if (wordElement) {
3357
- wordElements = [wordElement];
3358
- }
3359
- const allWordCharSpans = wordElements.flatMap(word => Array.from(word.querySelectorAll('span.char')));
3360
- const isGrowable = wordElement?.classList.contains('growable');
3361
- const isCharRise = wordElement?.classList.contains('char-rise');
3792
+ const typedWordElement = wordElement;
3793
+ const allWordElements = AmLyrics.getCachedVirtualWordElements(typedWordElement);
3794
+ const allWordCharSpans = AmLyrics.getCachedVirtualWordCharSpans(typedWordElement, charSpans);
3795
+ const isGrowable = typedWordElement?.classList.contains('growable');
3796
+ const isCharRise = typedWordElement?.classList.contains('char-rise');
3797
+ const isCharDrag = typedWordElement?.classList.contains('char-drag');
3362
3798
  const isFirstSyllable = syllable.getAttribute('data-syllable-index') === '0';
3363
3799
  const syllableStartMs = parseFloat(syllable.getAttribute('data-start-time') || '0');
3364
- const virtualWordStartMs = parseFloat(wordElement?.dataset.virtualWordStart || '');
3800
+ const virtualWordStartMs = parseFloat(typedWordElement?.dataset.virtualWordStart || '');
3365
3801
  const isFirstInVirtualWord = isFirstSyllable &&
3366
3802
  (!Number.isFinite(virtualWordStartMs) ||
3367
3803
  Math.abs(syllableStartMs - virtualWordStartMs) < 0.5);
@@ -3372,7 +3808,10 @@ let AmLyrics$1 = class AmLyrics extends i {
3372
3808
  const wordDurationMs = parseFloat(syllable.getAttribute('data-word-duration') ||
3373
3809
  syllable.getAttribute('data-duration') ||
3374
3810
  '0') || syllableDurationMs;
3375
- // Use a Map to collect animations like YouLyPlus
3811
+ const wordElapsedTimeMs = Number.isFinite(virtualWordStartMs)
3812
+ ? elapsedTimeMs + (syllableStartMs - virtualWordStartMs)
3813
+ : elapsedTimeMs;
3814
+ const charWipeDurationMs = Math.max(wordDurationMs, syllableDurationMs);
3376
3815
  const charAnimationsMap = new Map();
3377
3816
  const styleUpdates = [];
3378
3817
  // Step 1: Grow Pass
@@ -3420,41 +3859,81 @@ let AmLyrics$1 = class AmLyrics extends i {
3420
3859
  charAnimationsMap.set(span, `rise-char ${riseDurationMs}ms ease-in-out ${riseDelay}ms forwards`);
3421
3860
  });
3422
3861
  }
3862
+ if (isCharDrag && isFirstInVirtualWord && allWordCharSpans.length > 0) {
3863
+ const finalDuration = Math.max(wordDurationMs, syllableDurationMs);
3864
+ const baseDelayPerChar = AmLyrics.clamp(finalDuration * 0.15, 64, 118);
3865
+ const dragDurationMs = AmLyrics.clamp(finalDuration * 0.82, 560, 900);
3866
+ allWordCharSpans.forEach(span => {
3867
+ const charIndex = parseFloat(span.dataset.syllableCharIndex || '0');
3868
+ const dragDelay = baseDelayPerChar * charIndex;
3869
+ charAnimationsMap.set(span, `drag-char ${dragDurationMs}ms ease ${dragDelay}ms forwards`);
3870
+ });
3871
+ }
3423
3872
  // Step 2: Wipe Pass
3424
3873
  if (charSpans.length > 0) {
3425
- charSpans.forEach((span, charIndex) => {
3874
+ const wipeCharCount = allWordCharSpans.length ||
3875
+ charSpans.length ||
3876
+ AmLyrics.getVisibleCharacterCount(syllable);
3877
+ const wipeScale = AmLyrics.getLongWordWipeScale(wipeCharCount);
3878
+ AmLyrics.applyWipeShape(syllable, wipeCharCount);
3879
+ AmLyrics.ensureWordWipeGeometry(allWordCharSpans, wipeCharCount);
3880
+ allWordCharSpans.forEach(span => AmLyrics.applyWipeShape(span, wipeCharCount));
3881
+ const hasWordLevelWipe = !isFirstInVirtualWord &&
3882
+ (Boolean(typedWordElement?._wordWipeStarted) ||
3883
+ allWordCharSpans.some(span => span.style.animation.includes('wipe')));
3884
+ let charSpansToAnimate = charSpans;
3885
+ if (isFirstInVirtualWord) {
3886
+ charSpansToAnimate = allWordCharSpans;
3887
+ }
3888
+ else if (hasWordLevelWipe) {
3889
+ charSpansToAnimate = [];
3890
+ }
3891
+ if (charSpansToAnimate.length > 0 && allWordElements.length > 0) {
3892
+ allWordElements.forEach(element => {
3893
+ const target = element;
3894
+ target._wordWipeStarted = true;
3895
+ target._wordPreWipeKey = undefined;
3896
+ });
3897
+ }
3898
+ charSpansToAnimate.forEach((span, charIndex) => {
3426
3899
  const startPct = parseFloat(span.dataset.wipeStart || '0');
3427
3900
  const durationPct = parseFloat(span.dataset.wipeDuration || '0');
3428
- const wipeDelay = syllableDurationMs * startPct - elapsedTimeMs;
3429
- const wipeDuration = syllableDurationMs * durationPct;
3430
- const useStartAnimation = isFirstInContainer && charIndex === 0;
3431
- let charWipeAnimation = 'wipe';
3432
- if (useStartAnimation) {
3433
- charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
3901
+ const globalCharIndex = parseFloat(span.dataset.syllableCharIndex || `${charIndex}`);
3902
+ const hadCharPreWipe = span.classList.contains('pre-wipe-lead') ||
3903
+ (hadPreHighlight && globalCharIndex === 0);
3904
+ const charStartMs = charWipeDurationMs * startPct;
3905
+ const remainingWordWipeMs = Math.max(0, charWipeDurationMs - charStartMs);
3906
+ const wipeDelay = charStartMs - wordElapsedTimeMs;
3907
+ const wipeDuration = Math.min(charWipeDurationMs * durationPct * wipeScale, remainingWordWipeMs);
3908
+ const useStartAnimation = isFirstInContainer && globalCharIndex === 0 && !hadCharPreWipe;
3909
+ let charWipeAnimation = 'char-wipe';
3910
+ if (hadCharPreWipe) {
3911
+ charWipeAnimation = 'char-wipe';
3434
3912
  }
3435
- else {
3436
- charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
3913
+ else if (useStartAnimation) {
3914
+ charWipeAnimation = 'char-start-wipe';
3437
3915
  }
3438
3916
  const existingAnimation = charAnimationsMap.get(span) || span.style.animation || '';
3439
3917
  const animationParts = [];
3440
3918
  if (existingAnimation &&
3441
3919
  (existingAnimation.includes('grow-dynamic') ||
3442
- existingAnimation.includes('rise-char'))) {
3920
+ existingAnimation.includes('rise-char') ||
3921
+ existingAnimation.includes('drag-char'))) {
3443
3922
  animationParts.push(existingAnimation.split(',')[0].trim());
3444
3923
  }
3445
- if (charIndex > 0 && wipeDelay > 0 && wipeDuration > 0) {
3446
- const arrivalTime = (span.dataset.preWipeArrival
3447
- ? parseFloat(span.dataset.preWipeArrival)
3448
- : syllableDurationMs * startPct) - elapsedTimeMs;
3449
- const measuredPreWipeDuration = parseFloat(span.dataset.preWipeDuration || '100');
3450
- const preWipeDuration = Math.min(measuredPreWipeDuration, wipeDuration * 0.9, syllableDurationMs * 0.08, arrivalTime);
3451
- const animDelay = arrivalTime - preWipeDuration;
3924
+ if (globalCharIndex > 0 &&
3925
+ !hadCharPreWipe &&
3926
+ wipeDelay > 0 &&
3927
+ wipeDuration > 0) {
3928
+ const measuredDuration = Number.parseFloat(span.dataset.preWipeDuration || '100');
3929
+ const preWipeDuration = Math.min(measuredDuration, wipeDuration * 0.9, charWipeDurationMs * 0.08, wipeDelay);
3452
3930
  if (preWipeDuration >= 16) {
3453
- animationParts.push(`pre-wipe-char ${preWipeDuration}ms linear ${animDelay}ms none`);
3931
+ animationParts.push(`char-pre-wipe ${preWipeDuration}ms linear ${wipeDelay - preWipeDuration}ms none`);
3454
3932
  }
3455
3933
  }
3456
3934
  if (wipeDuration > 0) {
3457
- animationParts.push(`${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`);
3935
+ const wipeFillMode = hadCharPreWipe ? 'both' : 'forwards';
3936
+ animationParts.push(`${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms ${wipeFillMode}`);
3458
3937
  }
3459
3938
  if (animationParts.length > 0) {
3460
3939
  charAnimationsMap.set(span, animationParts.join(', '));
@@ -3464,9 +3943,15 @@ let AmLyrics$1 = class AmLyrics extends i {
3464
3943
  else {
3465
3944
  // Syllable-level wipe for regular (non-growable) words without chars
3466
3945
  const wipeRatio = parseFloat(syllable.getAttribute('data-wipe-ratio') || '1');
3467
- const visualDuration = syllableDurationMs * wipeRatio;
3946
+ const wipeCharCount = AmLyrics.getVisibleCharacterCount(syllable);
3947
+ const wipeScale = AmLyrics.getLongWordWipeScale(wipeCharCount);
3948
+ const visualDuration = syllableDurationMs * wipeRatio * wipeScale;
3949
+ AmLyrics.applyWipeShape(syllable, wipeCharCount);
3468
3950
  let wipeAnimation = 'wipe';
3469
- if (isFirstInContainer) {
3951
+ if (hadPreHighlight) {
3952
+ wipeAnimation = isRTL ? 'wipe-from-pre-rtl' : 'wipe-from-pre';
3953
+ }
3954
+ else if (isFirstInContainer) {
3470
3955
  wipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
3471
3956
  }
3472
3957
  else {
@@ -3479,16 +3964,25 @@ let AmLyrics$1 = class AmLyrics extends i {
3479
3964
  syllable.style.animation = `${currentWipeAnimation} ${visualDuration}ms ${isGap ? 'ease-out' : 'linear'} ${-elapsedTimeMs}ms forwards`;
3480
3965
  }
3481
3966
  // --- WRITE PHASE ---
3967
+ if (allWordElements.length > 0) {
3968
+ allWordElements.forEach(element => {
3969
+ const target = element;
3970
+ target._wordPreWipeKey = undefined;
3971
+ });
3972
+ }
3482
3973
  classList.remove('pre-highlight');
3483
3974
  classList.add('highlight');
3975
+ allWordCharSpans.forEach(span => AmLyrics.clearPreWipeLead(span));
3976
+ // Apply keyframe variables before assigning animation strings so the
3977
+ // first painted frame never uses fallback transform values.
3978
+ for (const update of styleUpdates) {
3979
+ update.element.style.setProperty(update.property, update.value);
3980
+ }
3484
3981
  for (const [span, animationString] of charAnimationsMap.entries()) {
3485
3982
  span.style.willChange = 'transform';
3983
+ span.style.removeProperty('background-color');
3486
3984
  span.style.animation = animationString;
3487
3985
  }
3488
- // Apply style updates
3489
- for (const update of styleUpdates) {
3490
- update.element.style.setProperty(update.property, update.value);
3491
- }
3492
3986
  }
3493
3987
  /**
3494
3988
  * Reset syllable animation state
@@ -3512,10 +4006,19 @@ let AmLyrics$1 = class AmLyrics extends i {
3512
4006
  el.style.animation = '';
3513
4007
  el.style.transition = 'none';
3514
4008
  el.style.backgroundColor = 'var(--lyplus-text-secondary)';
4009
+ AmLyrics.clearPreWipeLead(el);
3515
4010
  }
3516
4011
  // Immediately remove all state classes
3517
4012
  syllable.classList.remove('highlight', 'finished', 'pre-highlight', 'cleanup');
3518
4013
  }
4014
+ static resetWordAnimationState(line) {
4015
+ const wordElements = line.querySelectorAll('.lyrics-word');
4016
+ wordElements.forEach(wordElement => {
4017
+ const target = wordElement;
4018
+ target._wordPreWipeKey = undefined;
4019
+ target._wordWipeStarted = false;
4020
+ });
4021
+ }
3519
4022
  /**
3520
4023
  * Reset all syllables in a line — batches deferred cleanup into a single rAF
3521
4024
  */
@@ -3523,6 +4026,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3523
4026
  if (!line)
3524
4027
  return;
3525
4028
  line.classList.remove('persist-highlight');
4029
+ AmLyrics.resetWordAnimationState(line);
3526
4030
  // eslint-disable-next-line no-param-reassign
3527
4031
  line._cachedSyllableElements = null;
3528
4032
  const syllables = line.getElementsByClassName('lyrics-syllable');
@@ -3554,6 +4058,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3554
4058
  if (!line)
3555
4059
  return;
3556
4060
  line.classList.remove('persist-highlight');
4061
+ AmLyrics.resetWordAnimationState(line);
3557
4062
  const syllables = line.getElementsByClassName('lyrics-syllable');
3558
4063
  for (let i = 0; i < syllables.length; i += 1) {
3559
4064
  const s = syllables[i];
@@ -3571,6 +4076,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3571
4076
  el.style.removeProperty('background-color');
3572
4077
  el.style.removeProperty('transition');
3573
4078
  el.style.removeProperty('filter');
4079
+ AmLyrics.clearPreWipeLead(el);
3574
4080
  }
3575
4081
  }
3576
4082
  }
@@ -3607,19 +4113,26 @@ let AmLyrics$1 = class AmLyrics extends i {
3607
4113
  syllable.style.animation = '';
3608
4114
  syllable.style.removeProperty('--pre-wipe-duration');
3609
4115
  syllable.style.removeProperty('--pre-wipe-delay');
4116
+ syllable.style.removeProperty('background-color');
4117
+ AmLyrics.applyWipeShape(syllable, AmLyrics.getVisibleCharacterCount(syllable));
3610
4118
  const chars = syllable.querySelectorAll('span.char');
3611
4119
  for (let ci = 0; ci < chars.length; ci += 1) {
3612
4120
  const charEl = chars[ci];
3613
4121
  const currentAnim = charEl.style.animation || '';
3614
4122
  if (currentAnim.includes('grow-dynamic') ||
3615
- currentAnim.includes('rise-char')) {
4123
+ currentAnim.includes('rise-char') ||
4124
+ currentAnim.includes('drag-char')) {
3616
4125
  const parts = currentAnim.split(',').map(p => p.trim());
3617
- const transformAnim = parts.find(p => p.includes('grow-dynamic') || p.includes('rise-char'));
4126
+ const transformAnim = parts.find(p => p.includes('grow-dynamic') ||
4127
+ p.includes('rise-char') ||
4128
+ p.includes('drag-char'));
3618
4129
  charEl.style.animation = transformAnim || '';
3619
4130
  }
3620
4131
  else {
3621
4132
  charEl.style.animation = '';
3622
4133
  }
4134
+ charEl.style.backgroundColor = 'var(--lyplus-text-primary)';
4135
+ AmLyrics.clearPreWipeLead(charEl);
3623
4136
  }
3624
4137
  }
3625
4138
  }
@@ -3660,14 +4173,16 @@ let AmLyrics$1 = class AmLyrics extends i {
3660
4173
  // Early exit check
3661
4174
  if (!(currentTimeMs < startTime - 1000 && !hasActiveState)) {
3662
4175
  let preHighlightReset = false;
3663
- // Pre-highlight reset logic
3664
- if (hasPreHighlight && i > 0) {
3665
- const prevSyllable = syllables[i - 1];
3666
- if (!prevSyllable.classList.contains('highlight')) {
3667
- classList.remove('pre-highlight');
3668
- syllable.style.removeProperty('--pre-wipe-duration');
3669
- syllable.style.removeProperty('--pre-wipe-delay');
3670
- syllable.style.animation = '';
4176
+ // Before the syllable starts, pre-highlight only belongs beside a
4177
+ // previous active word. Once the syllable starts, updateSyllableAnimation
4178
+ // consumes the class so the actual wipe can continue from the pre-wipe
4179
+ // pose instead of restarting from the beginning.
4180
+ if (hasPreHighlight && currentTimeMs < startTime) {
4181
+ const prevSyllable = AmLyrics.getPreviousNonTransliterationSyllable(syllables, i);
4182
+ const previousCarriesHighlight = prevSyllable?.classList.contains('highlight') ||
4183
+ prevSyllable?.classList.contains('finished');
4184
+ if (!previousCarriesHighlight) {
4185
+ AmLyrics.clearPreHighlight(syllable);
3671
4186
  preHighlightReset = true;
3672
4187
  }
3673
4188
  }
@@ -3695,6 +4210,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3695
4210
  // Not yet started
3696
4211
  AmLyrics.resetSyllable(syllable);
3697
4212
  }
4213
+ AmLyrics.maybePreWipeNextWord(syllables, i, currentTimeMs, endTime);
3698
4214
  }
3699
4215
  }
3700
4216
  }
@@ -4004,6 +4520,9 @@ let AmLyrics$1 = class AmLyrics extends i {
4004
4520
  data-end-time="${endTimeMs}"
4005
4521
  data-duration="${durationMs}"
4006
4522
  data-syllable-index="${syllableIndex}"
4523
+ data-word-index="${syllableIndex}"
4524
+ data-word-length="${syllable.text.replace(/\s/g, '')
4525
+ .length}"
4007
4526
  data-wipe-ratio="1"
4008
4527
  >${syllable.text}</span
4009
4528
  >${bgRomanizedText}</span
@@ -4025,13 +4544,13 @@ let AmLyrics$1 = class AmLyrics extends i {
4025
4544
  const groupGrowable = lineData?.groupGrowable ?? [];
4026
4545
  const groupGlowing = lineData?.groupGlowing ?? [];
4027
4546
  const groupCharRise = lineData?.groupCharRise ?? [];
4547
+ const groupCharDrag = lineData?.groupCharDrag ?? [];
4028
4548
  const vwFullText = lineData?.vwFullText ?? [];
4029
4549
  const vwFullDuration = lineData?.vwFullDuration ?? [];
4030
4550
  const vwCharOffset = lineData?.vwCharOffset ?? [];
4031
4551
  const vwStartMs = lineData?.vwStartMs ?? [];
4032
4552
  const vwEndMs = lineData?.vwEndMs ?? [];
4033
4553
  const lineIsRTL = lineData?.lineIsRTL ?? false;
4034
- // Create main vocals using YouLyPlus syllable structure
4035
4554
  const mainVocalElement = b `<p
4036
4555
  class="main-vocal-container ${lineIsRTL ? 'rtl-text' : ''}"
4037
4556
  >
@@ -4039,25 +4558,23 @@ let AmLyrics$1 = class AmLyrics extends i {
4039
4558
  const isGrowable = groupGrowable[groupIdx];
4040
4559
  const isGlowing = groupGlowing[groupIdx];
4041
4560
  const isCharRise = groupCharRise[groupIdx];
4042
- const isAnimatedByChar = isGrowable || isCharRise;
4561
+ const isCharDrag = groupCharDrag[groupIdx];
4562
+ const isAnimatedByChar = isGrowable || isCharRise || isCharDrag;
4043
4563
  const groupLineSynced = group.some(s => s.lineSynced);
4044
4564
  const wordText = isAnimatedByChar ? vwFullText[groupIdx] : '';
4045
4565
  const wordDuration = isAnimatedByChar
4046
4566
  ? vwFullDuration[groupIdx]
4047
4567
  : 0;
4048
- const wordNumChars = wordText.length;
4568
+ const wordNumChars = wordText.replace(/\s/g, '').length;
4049
4569
  const groupCharOffset = isAnimatedByChar
4050
4570
  ? vwCharOffset[groupIdx]
4051
4571
  : 0;
4052
- const virtualWordId = isAnimatedByChar
4053
- ? `${lineIndex}:${vwStartMs[groupIdx]}:${vwEndMs[groupIdx]}`
4054
- : '';
4055
- const virtualWordStart = isAnimatedByChar
4056
- ? vwStartMs[groupIdx]
4057
- : '';
4058
- const virtualWordEnd = isAnimatedByChar ? vwEndMs[groupIdx] : '';
4572
+ const virtualWordId = `${lineIndex}:${vwStartMs[groupIdx]}:${vwEndMs[groupIdx]}`;
4573
+ const virtualWordStart = vwStartMs[groupIdx];
4574
+ const virtualWordEnd = vwEndMs[groupIdx];
4059
4575
  let sylCharAccumulator = 0;
4060
4576
  const groupText = group.map(s => s.text).join('');
4577
+ const visibleWordLength = groupText.replace(/\s/g, '').length;
4061
4578
  const shouldAllowBreak = groupText.trim().length >= 16 ||
4062
4579
  /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(groupText);
4063
4580
  // Calculate dynamic rise duration based on the audio duration of the word
@@ -4069,9 +4586,9 @@ let AmLyrics$1 = class AmLyrics extends i {
4069
4586
  return b `<span
4070
4587
  class="lyrics-word${isGrowable ? ' growable' : ''}${isCharRise
4071
4588
  ? ' char-rise'
4072
- : ''}${isGlowing ? ' glowing' : ''}${shouldAllowBreak
4073
- ? ' allow-break'
4074
- : ''}"
4589
+ : ''}${isCharDrag ? ' char-drag' : ''}${isGlowing
4590
+ ? ' glowing'
4591
+ : ''}${shouldAllowBreak ? ' allow-break' : ''}"
4075
4592
  data-virtual-word-id="${virtualWordId}"
4076
4593
  data-virtual-word-start="${virtualWordStart}"
4077
4594
  data-virtual-word-end="${virtualWordEnd}"
@@ -4098,13 +4615,26 @@ let AmLyrics$1 = class AmLyrics extends i {
4098
4615
  : '';
4099
4616
  let syllableContent = text;
4100
4617
  if (isAnimatedByChar) {
4101
- let charIndexInsideSyllable = 0;
4102
4618
  const numCharsInSyllable = text.replace(/\s/g, '').length || 1;
4619
+ const hasVirtualTiming = wordDuration > 0 && Number.isFinite(virtualWordStart);
4620
+ const syllableStartRatio = hasVirtualTiming
4621
+ ? AmLyrics.clamp((startTimeMs - virtualWordStart) / wordDuration, 0, 1)
4622
+ : 0;
4623
+ const syllableDurationRatio = hasVirtualTiming
4624
+ ? AmLyrics.clamp(durationMs / wordDuration, 0, 1)
4625
+ : 1;
4626
+ let charIndexInsideSyllable = 0;
4103
4627
  syllableContent = b `${text.split('').map(char => {
4104
4628
  if (char === ' ')
4105
4629
  return ' ';
4106
4630
  const charIndexInsideWord = groupCharOffset + sylCharAccumulator;
4107
- const charStartPercentVal = charIndexInsideSyllable / numCharsInSyllable;
4631
+ const localCharIndex = charIndexInsideSyllable;
4632
+ const visibleWordChars = Math.max(1, wordNumChars);
4633
+ const charStartPercentVal = AmLyrics.clamp(syllableStartRatio +
4634
+ (localCharIndex / numCharsInSyllable) *
4635
+ syllableDurationRatio, 0, 1);
4636
+ const charDurationPercentVal = syllableDurationRatio / numCharsInSyllable ||
4637
+ 1 / visibleWordChars;
4108
4638
  sylCharAccumulator += 1;
4109
4639
  charIndexInsideSyllable += 1;
4110
4640
  const minDuration = 400;
@@ -4151,21 +4681,37 @@ let AmLyrics$1 = class AmLyrics extends i {
4151
4681
  const normalizedGrowth = (charMaxScale - 1.0) / 0.1;
4152
4682
  const effectiveDuration = (wordDuration + durationMs * 2) / 3;
4153
4683
  const peakMultiplier = Math.min(1, Math.max(0.3, effectiveDuration / 2000));
4154
- const charTranslateYPeak = -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
4684
+ const baseTranslateYPeak = -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
4155
4685
  const position = (charIndexInsideWord + 0.5) / wordNumChars;
4156
4686
  const horizontalOffset = (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25);
4687
+ const isDragMotion = isCharDrag;
4688
+ let charTranslateYPeak = baseTranslateYPeak;
4689
+ if (isCharRise) {
4690
+ charTranslateYPeak = 0;
4691
+ }
4692
+ else if (isDragMotion) {
4693
+ charTranslateYPeak = -0.78;
4694
+ }
4695
+ let motionHorizontalOffset = horizontalOffset;
4696
+ if (isCharRise) {
4697
+ motionHorizontalOffset = 0;
4698
+ }
4699
+ else if (isDragMotion) {
4700
+ motionHorizontalOffset = 0;
4701
+ }
4157
4702
  return b `<span
4158
4703
  class="char"
4159
4704
  data-char-index="${charIndexInsideWord}"
4160
4705
  data-syllable-char-index="${charIndexInsideWord}"
4161
4706
  data-wipe-start="${charStartPercentVal.toFixed(4)}"
4162
- data-wipe-duration="${(1 / numCharsInSyllable).toFixed(4)}"
4707
+ data-wipe-duration="${charDurationPercentVal.toFixed(4)}"
4163
4708
  data-horizontal-offset="${horizontalOffset.toFixed(2)}"
4164
4709
  data-max-scale="${charMaxScale.toFixed(3)}"
4165
4710
  data-matrix-scale="${(charMaxScale * 0.98).toFixed(3)}"
4166
- data-char-offset-x="${(horizontalOffset * 0.98).toFixed(2)}"
4711
+ data-char-offset-x="${(motionHorizontalOffset * 0.98).toFixed(2)}"
4167
4712
  data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
4168
4713
  data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
4714
+ style="--word-wipe-width: ${visibleWordChars}ch; --char-wipe-position: -${charIndexInsideWord}ch"
4169
4715
  >${char}</span
4170
4716
  >`;
4171
4717
  })}`;
@@ -4183,6 +4729,8 @@ let AmLyrics$1 = class AmLyrics extends i {
4183
4729
  data-duration="${durationMs}"
4184
4730
  data-word-duration="${wordDuration}"
4185
4731
  data-syllable-index="${sylIdx}"
4732
+ data-word-index="${groupIdx}"
4733
+ data-word-length="${visibleWordLength}"
4186
4734
  data-wipe-ratio="1"
4187
4735
  >${syllableContent}</span
4188
4736
  >${romanizedText}</span
@@ -4415,13 +4963,14 @@ let AmLyrics$1 = class AmLyrics extends i {
4415
4963
  <button
4416
4964
  class="download-button source-switch-btn"
4417
4965
  title="Switch Lyrics Source"
4418
- 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;"
4419
4966
  @click=${this.switchSource}
4420
4967
  ?disabled=${this.isFetchingAlternatives}
4421
4968
  >
4422
4969
  <svg
4423
- class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
4424
- style="margin-right: 4px;"
4970
+ class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up ${this
4971
+ .isFetchingAlternatives
4972
+ ? 'is-loading'
4973
+ : ''}"
4425
4974
  xmlns="http://www.w3.org/2000/svg"
4426
4975
  width="12"
4427
4976
  height="12"
@@ -4479,9 +5028,6 @@ let AmLyrics$1 = class AmLyrics extends i {
4479
5028
  }
4480
5029
  };
4481
5030
  AmLyrics$1.styles = i$3 `
4482
- /* ==========================================================================
4483
- YOULYPLUS-INSPIRED STYLING - Design Tokens & Variables
4484
- ========================================================================== */
4485
5031
  :host {
4486
5032
  --lyplus-lyrics-palette: var(
4487
5033
  --am-lyrics-highlight-color,
@@ -4510,6 +5056,8 @@ AmLyrics$1.styles = i$3 `
4510
5056
  --lyplus-blur-amount: 0.07em;
4511
5057
  --lyplus-blur-amount-near: 0.035em;
4512
5058
  --lyplus-fade-gap-timing-function: ease-out;
5059
+ --wipe-gradient-width: 0.75em;
5060
+ --wipe-gradient-half: 0.375em;
4513
5061
 
4514
5062
  --lyrics-scroll-padding-top: 25%;
4515
5063
 
@@ -4537,6 +5085,9 @@ AmLyrics$1.styles = i$3 `
4537
5085
  max-height: 100vh;
4538
5086
  overflow-y: auto;
4539
5087
  -webkit-overflow-scrolling: touch;
5088
+ -webkit-touch-callout: none;
5089
+ -webkit-user-select: none;
5090
+ user-select: none;
4540
5091
  box-sizing: border-box;
4541
5092
  scrollbar-width: none;
4542
5093
  overflow-anchor: none;
@@ -4666,7 +5217,7 @@ AmLyrics$1.styles = i$3 `
4666
5217
  .lyrics-line.bg-expanded .background-vocal-container {
4667
5218
  max-height: 4em;
4668
5219
  opacity: 1;
4669
- will-change: max-height, opacity;
5220
+ will-change: opacity;
4670
5221
  }
4671
5222
 
4672
5223
  .lyrics-line.bg-expanded .background-vocal-wrap {
@@ -4683,6 +5234,18 @@ AmLyrics$1.styles = i$3 `
4683
5234
  opacity: 1;
4684
5235
  }
4685
5236
 
5237
+ /* Predictive scrolling begins before the next timestamp. Start dimming
5238
+ the outgoing line at the same moment so it settles with the scroll. */
5239
+ .lyrics-line.scroll-exiting {
5240
+ opacity: 0.8;
5241
+ color: var(--lyplus-text-secondary);
5242
+ transition:
5243
+ opacity var(--scroll-duration, 400ms) cubic-bezier(0.41, 0, 0.12, 0.99),
5244
+ transform var(--scroll-duration, 400ms)
5245
+ cubic-bezier(0.41, 0, 0.12, 0.99) var(--lyrics-line-delay, 0ms),
5246
+ filter var(--scroll-duration, 400ms) ease;
5247
+ }
5248
+
4686
5249
  .lyrics-line.persist-highlight {
4687
5250
  filter: none !important;
4688
5251
  opacity: 1;
@@ -4818,11 +5381,22 @@ AmLyrics$1.styles = i$3 `
4818
5381
  white-space: nowrap;
4819
5382
  }
4820
5383
 
5384
+ .lyrics-word.char-drag {
5385
+ display: inline-block;
5386
+ vertical-align: baseline;
5387
+ white-space: nowrap;
5388
+ }
5389
+
4821
5390
  .lyrics-word.char-rise.allow-break {
4822
5391
  display: inline;
4823
5392
  white-space: normal;
4824
5393
  }
4825
5394
 
5395
+ .lyrics-word.char-drag.allow-break {
5396
+ display: inline;
5397
+ white-space: normal;
5398
+ }
5399
+
4826
5400
  .lyrics-syllable-wrap {
4827
5401
  display: inline;
4828
5402
  }
@@ -4877,44 +5451,30 @@ AmLyrics$1.styles = i$3 `
4877
5451
  .lyrics-line.active:not(.lyrics-gap)
4878
5452
  .lyrics-syllable.pre-highlight.no-chars {
4879
5453
  background-repeat: no-repeat;
4880
- background-image:
4881
- linear-gradient(
4882
- 90deg,
4883
- #ffffff00 0%,
4884
- var(--lyplus-text-primary, #fff) 50%,
4885
- #0000 100%
4886
- ),
4887
- linear-gradient(
4888
- 90deg,
4889
- var(--lyplus-text-primary, #fff) 100%,
4890
- #0000 100%
4891
- );
4892
- background-size:
4893
- 0.5em 100%,
4894
- 0% 100%;
4895
- background-position:
4896
- -0.5em 0%,
4897
- -0.25em 0%;
5454
+ background-image: linear-gradient(
5455
+ 90deg,
5456
+ var(--lyplus-text-primary, #fff) 0%,
5457
+ var(--lyplus-text-primary, #fff)
5458
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5459
+ #0000 100%
5460
+ );
5461
+ background-size: 0% 100%;
5462
+ background-position: left;
4898
5463
  }
4899
5464
 
4900
5465
  .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.highlight.rtl-text,
4901
5466
  .lyrics-line.active:not(.lyrics-gap)
4902
5467
  .lyrics-syllable.pre-highlight.rtl-text {
4903
5468
  direction: rtl;
4904
- background-image:
4905
- linear-gradient(
4906
- -90deg,
4907
- var(--lyplus-text-primary) 0%,
4908
- transparent 100%
4909
- ),
4910
- linear-gradient(
4911
- -90deg,
4912
- var(--lyplus-text-primary) 100%,
4913
- transparent 100%
4914
- );
4915
- background-position:
4916
- calc(100% + 0.5em) 0%,
4917
- right 0%;
5469
+ background-image: linear-gradient(
5470
+ -90deg,
5471
+ var(--lyplus-text-primary) 0%,
5472
+ var(--lyplus-text-primary)
5473
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5474
+ transparent 100%
5475
+ );
5476
+ background-size: 0% 100%;
5477
+ background-position: right 0%;
4918
5478
  }
4919
5479
 
4920
5480
  /* Background vocals: muted gray wipe instead of white.
@@ -4931,18 +5491,13 @@ AmLyrics$1.styles = i$3 `
4931
5491
  .lyrics-line.pre-active
4932
5492
  .background-vocal-container
4933
5493
  .lyrics-syllable.pre-highlight.no-chars {
4934
- background-image:
4935
- linear-gradient(
4936
- 90deg,
4937
- #ffffff00 0%,
4938
- color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 50%,
4939
- #0000 100%
4940
- ),
4941
- linear-gradient(
4942
- 90deg,
4943
- color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 100%,
4944
- #0000 100%
4945
- );
5494
+ background-image: linear-gradient(
5495
+ 90deg,
5496
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 0%,
5497
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888)
5498
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5499
+ #0000 100%
5500
+ );
4946
5501
  }
4947
5502
 
4948
5503
  .lyrics-line.active
@@ -4957,17 +5512,13 @@ AmLyrics$1.styles = i$3 `
4957
5512
  .lyrics-line.pre-active
4958
5513
  .background-vocal-container
4959
5514
  .lyrics-syllable.pre-highlight.rtl-text {
4960
- background-image:
4961
- linear-gradient(
4962
- -90deg,
4963
- color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 0%,
4964
- transparent 100%
4965
- ),
4966
- linear-gradient(
4967
- -90deg,
4968
- color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 100%,
4969
- transparent 100%
4970
- );
5515
+ background-image: linear-gradient(
5516
+ -90deg,
5517
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 0%,
5518
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888)
5519
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5520
+ transparent 100%
5521
+ );
4971
5522
  }
4972
5523
 
4973
5524
  /* Non-growable words float up with a gentle curve */
@@ -4991,6 +5542,10 @@ AmLyrics$1.styles = i$3 `
4991
5542
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
4992
5543
  }
4993
5544
 
5545
+ .lyrics-word.char-drag .lyrics-syllable.cleanup .char {
5546
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
5547
+ }
5548
+
4994
5549
  .lyrics-line.persist-highlight
4995
5550
  .lyrics-word.growable
4996
5551
  .lyrics-syllable.finished
@@ -4998,6 +5553,10 @@ AmLyrics$1.styles = i$3 `
4998
5553
  .lyrics-line.persist-highlight
4999
5554
  .lyrics-word.char-rise
5000
5555
  .lyrics-syllable.finished
5556
+ .char,
5557
+ .lyrics-line.persist-highlight
5558
+ .lyrics-word.char-drag
5559
+ .lyrics-syllable.finished
5001
5560
  .char {
5002
5561
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
5003
5562
  }
@@ -5108,6 +5667,10 @@ AmLyrics$1.styles = i$3 `
5108
5667
  transform 0.7s ease;
5109
5668
  }
5110
5669
 
5670
+ .lyrics-word.char-drag span.char {
5671
+ transition: color 0.18s;
5672
+ }
5673
+
5111
5674
  /* Active char spans: structural only, wipe animation sets gradient */
5112
5675
  .lyrics-line.active .lyrics-syllable span.char {
5113
5676
  background-clip: text;
@@ -5126,52 +5689,34 @@ AmLyrics$1.styles = i$3 `
5126
5689
  #0000 100%
5127
5690
  );
5128
5691
  background-size:
5129
- 0.5em 100%,
5692
+ var(--wipe-gradient-width, 0.75em) 100%,
5130
5693
  0% 100%;
5131
5694
  background-position:
5132
- -0.5em 0%,
5133
- -0.25em 0%;
5695
+ calc(-1 * var(--wipe-gradient-width, 0.75em)) 0%,
5696
+ left;
5134
5697
  transition:
5135
5698
  transform 0.7s ease,
5136
5699
  color 0.18s;
5137
5700
  }
5138
5701
 
5139
5702
  .lyrics-line.active .lyrics-syllable span.char.highlight {
5140
- background-image:
5141
- linear-gradient(
5142
- -90deg,
5143
- var(--lyplus-text-primary, #fff) 0%,
5144
- #0000 100%
5145
- ),
5146
- linear-gradient(
5147
- -90deg,
5148
- var(--lyplus-text-primary, #fff) 100%,
5149
- #0000 100%
5150
- );
5151
- background-position:
5152
- calc(100% + 0.5em) 0%,
5153
- calc(100% + 0.25em) 0%;
5703
+ background-image: linear-gradient(
5704
+ -90deg,
5705
+ var(--lyplus-text-primary, #fff) 0%,
5706
+ var(--lyplus-text-primary, #fff)
5707
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5708
+ #0000 100%
5709
+ );
5710
+ background-size: 0% 100%;
5711
+ background-position: right 0%;
5154
5712
  }
5155
5713
 
5156
- .lyrics-line.active .lyrics-syllable.pre-highlight span.char {
5157
- background-image:
5158
- linear-gradient(
5159
- 90deg,
5160
- #ffffff00 0%,
5161
- var(--lyplus-text-primary, #fff) 50%,
5162
- #0000 100%
5163
- ),
5164
- linear-gradient(
5165
- 90deg,
5166
- var(--lyplus-text-primary, #fff) 100%,
5167
- #0000 100%
5168
- );
5169
- background-size:
5170
- 0.75em 100%,
5171
- 0% 100%;
5172
- background-position:
5173
- -0.85em 0%,
5174
- -0.25em 0%;
5714
+ .lyrics-line.active .lyrics-syllable span.char.pre-wipe-lead {
5715
+ animation-name: char-pre-wipe;
5716
+ animation-duration: var(--pre-wipe-duration);
5717
+ animation-delay: var(--pre-wipe-delay);
5718
+ animation-timing-function: linear;
5719
+ animation-fill-mode: forwards;
5175
5720
  }
5176
5721
 
5177
5722
  /* ==========================================================================
@@ -5248,13 +5793,7 @@ AmLyrics$1.styles = i$3 `
5248
5793
  color: var(--lyplus-text-primary) !important;
5249
5794
  }
5250
5795
 
5251
- .lyrics-line.pre-active .lyrics-syllable.line-synced {
5252
- animation: fade-in-line 0.14s ease-out forwards !important;
5253
- color: var(--lyplus-text-primary) !important;
5254
- }
5255
-
5256
- .lyrics-line.active .lyrics-syllable.line-synced span.char,
5257
- .lyrics-line.pre-active .lyrics-syllable.line-synced span.char {
5796
+ .lyrics-line.active .lyrics-syllable.line-synced span.char {
5258
5797
  background-image: none !important;
5259
5798
  background-color: var(--lyplus-text-primary) !important;
5260
5799
  transition: background-color 120ms ease-out !important;
@@ -5427,6 +5966,49 @@ AmLyrics$1.styles = i$3 `
5427
5966
  gap: 4px;
5428
5967
  }
5429
5968
 
5969
+ .source-switch-btn {
5970
+ position: relative;
5971
+ display: inline-flex;
5972
+ align-items: center;
5973
+ padding: 2px 8px;
5974
+ border: 1px solid rgba(255, 255, 255, 0.2);
5975
+ min-height: 28px;
5976
+ background: transparent;
5977
+ border-radius: 6px;
5978
+ color: #aaa;
5979
+ cursor: pointer;
5980
+ font-family: inherit;
5981
+ font-size: 11px;
5982
+ transition:
5983
+ color 0.2s ease,
5984
+ border-color 0.2s ease,
5985
+ background-color 0.2s ease,
5986
+ transform 0.12s ease;
5987
+ }
5988
+
5989
+ .source-switch-btn::before {
5990
+ content: '';
5991
+ position: absolute;
5992
+ inset: -6px;
5993
+ }
5994
+
5995
+ .source-switch-btn:active:not(:disabled) {
5996
+ transform: scale(0.96);
5997
+ }
5998
+
5999
+ .source-switch-btn:disabled {
6000
+ cursor: default;
6001
+ opacity: 0.7;
6002
+ }
6003
+
6004
+ .source-switch-svg {
6005
+ margin-right: 4px;
6006
+ }
6007
+
6008
+ .source-switch-svg.is-loading {
6009
+ animation: source-switch-spin 1s linear infinite;
6010
+ }
6011
+
5430
6012
  .control-button {
5431
6013
  background: transparent;
5432
6014
  border: 1px solid rgba(255, 255, 255, 0.3);
@@ -5435,7 +6017,10 @@ AmLyrics$1.styles = i$3 `
5435
6017
  font-size: 0.8em;
5436
6018
  color: rgba(255, 255, 255, 0.6);
5437
6019
  cursor: pointer;
5438
- transition: all 0.2s;
6020
+ transition:
6021
+ color 0.2s,
6022
+ border-color 0.2s,
6023
+ background-color 0.2s;
5439
6024
  font-weight: normal;
5440
6025
  }
5441
6026
 
@@ -5573,136 +6158,157 @@ AmLyrics$1.styles = i$3 `
5573
6158
  KEYFRAME ANIMATIONS
5574
6159
  ========================================================================== */
5575
6160
 
6161
+ @keyframes source-switch-spin {
6162
+ to {
6163
+ transform: rotate(360deg);
6164
+ }
6165
+ }
6166
+
5576
6167
  /* Wipe animation for syllables */
5577
6168
  @keyframes wipe {
5578
6169
  from {
5579
- background-size:
5580
- 0.75em 100%,
5581
- 0% 100%;
5582
- background-position:
5583
- -0.375em 0%,
5584
- left;
6170
+ background-size: 0% 100%;
6171
+ background-position: left;
5585
6172
  }
5586
6173
  to {
5587
- background-size:
5588
- 0.75em 100%,
5589
- 100% 100%;
5590
- background-position:
5591
- calc(100% + 0.375em) 0%,
5592
- left;
6174
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6175
+ background-position: left;
6176
+ }
6177
+ }
6178
+
6179
+ @keyframes wipe-from-pre {
6180
+ from {
6181
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6182
+ background-position: left;
6183
+ }
6184
+ to {
6185
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6186
+ background-position: left;
5593
6187
  }
5594
6188
  }
5595
6189
 
5596
6190
  @keyframes start-wipe {
5597
6191
  0% {
5598
- background-size:
5599
- 0.75em 100%,
5600
- 0% 100%;
5601
- background-position:
5602
- -0.75em 0%,
5603
- -0.375em 0%;
6192
+ background-size: 0% 100%;
6193
+ background-position: left;
5604
6194
  }
5605
6195
  100% {
5606
- background-size:
5607
- 0.75em 100%,
5608
- 100% 100%;
5609
- background-position:
5610
- calc(100% + 0.375em) 0%,
5611
- left;
6196
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6197
+ background-position: left;
5612
6198
  }
5613
6199
  }
5614
6200
 
5615
6201
  @keyframes wipe-rtl {
5616
6202
  from {
5617
- background-size:
5618
- 0.75em 100%,
5619
- 0% 100%;
5620
- background-position:
5621
- calc(100% + 0.375em) 0%,
5622
- calc(100% + 0.36em) 0%;
6203
+ background-size: 0% 100%;
6204
+ background-position: right 0%;
5623
6205
  }
5624
6206
  to {
5625
- background-size:
5626
- 0.75em 100%,
5627
- 100% 100%;
5628
- background-position:
5629
- -0.75em 0%,
5630
- right 0%;
6207
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6208
+ background-position: right 0%;
6209
+ }
6210
+ }
6211
+
6212
+ @keyframes wipe-from-pre-rtl {
6213
+ from {
6214
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6215
+ background-position: right 0%;
6216
+ }
6217
+ to {
6218
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6219
+ background-position: right 0%;
5631
6220
  }
5632
6221
  }
5633
6222
 
5634
6223
  @keyframes start-wipe-rtl {
5635
6224
  0% {
5636
- background-size:
5637
- 0.75em 100%,
5638
- 0% 100%;
5639
- background-position:
5640
- calc(100% + 0.75em) 0%,
5641
- calc(100% + 0.5em) 0%;
6225
+ background-size: 0% 100%;
6226
+ background-position: right 0%;
5642
6227
  }
5643
6228
  100% {
5644
- background-size:
5645
- 0.75em 100%,
5646
- 100% 100%;
5647
- background-position:
5648
- -0.75em 0%,
5649
- right 0%;
6229
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6230
+ background-position: right 0%;
5650
6231
  }
5651
6232
  }
5652
6233
 
5653
6234
  @keyframes pre-wipe-universal {
6235
+ from {
6236
+ background-size: 0% 100%;
6237
+ background-position: left;
6238
+ }
6239
+ to {
6240
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6241
+ background-position: left;
6242
+ }
6243
+ }
6244
+
6245
+ @keyframes pre-wipe-universal-rtl {
6246
+ from {
6247
+ background-size: 0% 100%;
6248
+ background-position: right 0%;
6249
+ }
6250
+ to {
6251
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6252
+ background-position: right 0%;
6253
+ }
6254
+ }
6255
+
6256
+ /* Character-rendered words use a separate moving gradient in front of
6257
+ their solid fill. This makes the individual glyph wipes read as one
6258
+ continuous word-level wipe. */
6259
+ @keyframes char-pre-wipe {
5654
6260
  from {
5655
6261
  background-size:
5656
- 0.75em 100%,
6262
+ var(--wipe-gradient-width, 0.75em) 100%,
5657
6263
  0% 100%;
5658
6264
  background-position:
5659
- -0.75em 0%,
6265
+ calc(-1 * var(--wipe-gradient-width, 0.75em)) 0%,
5660
6266
  left;
5661
6267
  }
5662
6268
  to {
5663
6269
  background-size:
5664
- 0.75em 100%,
6270
+ var(--wipe-gradient-width, 0.75em) 100%,
5665
6271
  0% 100%;
5666
6272
  background-position:
5667
- -0.375em 0%,
6273
+ calc(-1 * var(--wipe-gradient-half, 0.375em)) 0%,
5668
6274
  left;
5669
6275
  }
5670
6276
  }
5671
6277
 
5672
- @keyframes pre-wipe-universal-rtl {
6278
+ @keyframes char-start-wipe {
5673
6279
  from {
5674
6280
  background-size:
5675
- 0.75em 100%,
6281
+ var(--wipe-gradient-width, 0.75em) 100%,
5676
6282
  0% 100%;
5677
6283
  background-position:
5678
- calc(100% + 0.75em) 0%,
5679
- right 0%;
6284
+ calc(-1 * var(--wipe-gradient-width, 0.75em)) 0%,
6285
+ left;
5680
6286
  }
5681
6287
  to {
5682
6288
  background-size:
5683
- 0.75em 100%,
5684
- 0% 100%;
6289
+ var(--wipe-gradient-width, 0.75em) 100%,
6290
+ 100% 100%;
5685
6291
  background-position:
5686
- calc(100% + 0.375em) 0%,
5687
- right 0%;
6292
+ calc(100% + var(--wipe-gradient-half, 0.375em)) 0%,
6293
+ left;
5688
6294
  }
5689
6295
  }
5690
6296
 
5691
- @keyframes pre-wipe-char {
6297
+ @keyframes char-wipe {
5692
6298
  from {
5693
6299
  background-size:
5694
- 0.75em 100%,
6300
+ var(--wipe-gradient-width, 0.75em) 100%,
5695
6301
  0% 100%;
5696
6302
  background-position:
5697
- -0.75em 0%,
6303
+ calc(-1 * var(--wipe-gradient-half, 0.375em)) 0%,
5698
6304
  left;
5699
6305
  }
5700
6306
  to {
5701
6307
  background-size:
5702
- 0.75em 100%,
5703
- 0% 100%;
6308
+ var(--wipe-gradient-width, 0.75em) 100%,
6309
+ 100% 100%;
5704
6310
  background-position:
5705
- -0.375em 0%,
6311
+ calc(100% + var(--wipe-gradient-half, 0.375em)) 0%,
5706
6312
  left;
5707
6313
  }
5708
6314
  }
@@ -5797,6 +6403,15 @@ AmLyrics$1.styles = i$3 `
5797
6403
  }
5798
6404
  }
5799
6405
 
6406
+ @keyframes drag-char {
6407
+ 0% {
6408
+ transform: translate3d(0, 0, 0);
6409
+ }
6410
+ 100% {
6411
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
6412
+ }
6413
+ }
6414
+
5800
6415
  @keyframes grow-static {
5801
6416
  0%,
5802
6417
  100% {