@uimaxbai/am-lyrics 1.5.4 → 1.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.5';
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 = 120;
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,6 +499,7 @@ 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
505
  const activeLines = this.lyricsContainer.querySelectorAll('.lyrics-line.active, .lyrics-line.pre-active, .lyrics-line.bg-expanded');
@@ -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,7 +1948,9 @@ 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 {
@@ -1914,12 +1966,13 @@ 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');
1975
+ lineElement.classList.add('active');
1923
1976
  lineElement.classList.remove('pre-active');
1924
1977
  const preIdx = this.preActiveLineElements.indexOf(lineElement);
1925
1978
  if (preIdx !== -1)
@@ -2139,8 +2192,12 @@ let AmLyrics$1 = class AmLyrics extends i {
2139
2192
  for (const lineIndex of activeLines) {
2140
2193
  const lineEl = this._getLineElement(lineIndex);
2141
2194
  if (lineEl)
2142
- lineEl.classList.add('active', 'bg-expanded');
2195
+ lineEl.classList.add('active');
2143
2196
  }
2197
+ const primaryActiveLine = this.getPrimaryActiveLineIndex(activeLines);
2198
+ this.setBackgroundExpandedLine(primaryActiveLine !== null
2199
+ ? this._getLineElement(primaryActiveLine)
2200
+ : null);
2144
2201
  // Trigger a faux time-change so that updateSyllablesForLine fires
2145
2202
  // to setup inline syllable CSS wipe animations for whatever the current time is
2146
2203
  this._onTimeChanged(0, this.currentTime);
@@ -2179,6 +2236,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2179
2236
  this.preActiveLineElements = [];
2180
2237
  this.positionedLineElements = [];
2181
2238
  this.activeGapLineElements = [];
2239
+ this.clearBackgroundExpandedLine();
2182
2240
  this.setUserScrolling(false);
2183
2241
  // Cancel any running animations
2184
2242
  if (this.animationFrameId) {
@@ -2233,6 +2291,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2233
2291
  // Don't override it with a scroll back to the last lyric.
2234
2292
  const footer = this.lyricsContainer.querySelector('.lyrics-footer');
2235
2293
  if (footer?.classList.contains('active')) {
2294
+ this.setBackgroundExpandedLine(null);
2236
2295
  return;
2237
2296
  }
2238
2297
  // 1. Compute scroll lookahead based on gap to next line (YouLyPlus style)
@@ -2273,17 +2332,21 @@ let AmLyrics$1 = class AmLyrics extends i {
2273
2332
  targetElement = this._getLineElement(targetLineIdx);
2274
2333
  }
2275
2334
  }
2276
- if (!targetElement)
2335
+ if (!targetElement) {
2336
+ this.setBackgroundExpandedLine(null);
2277
2337
  return;
2278
- // Unblur the upcoming target line early (pre-active) so background
2279
- // vocals start their max-height/opacity transition in sync with scroll.
2338
+ }
2339
+ const scrollDuration = scrollLookAheadMs;
2340
+ targetElement.style.setProperty('--scroll-duration', `${scrollDuration}ms`);
2341
+ this.setBackgroundExpandedLine(targetElement);
2342
+ // Unblur the upcoming target line early while the separate bg-expanded
2343
+ // class starts background vocal height/opacity in sync with scroll.
2280
2344
  if (!targetElement.classList.contains('active')) {
2281
2345
  targetElement.classList.add('pre-active');
2282
2346
  if (!this.preActiveLineElements.includes(targetElement)) {
2283
2347
  this.preActiveLineElements.push(targetElement);
2284
2348
  }
2285
2349
  }
2286
- const scrollDuration = scrollLookAheadMs;
2287
2350
  this.focusLine(targetElement, forceScroll, scrollDuration);
2288
2351
  }
2289
2352
  _getTextWidth(text, font) {
@@ -2356,6 +2419,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2356
2419
  this.preActiveLineElements = [];
2357
2420
  this.positionedLineElements = [];
2358
2421
  this.activeGapLineElements = [];
2422
+ this.clearBackgroundExpandedLine();
2359
2423
  this.visibilityObserver?.disconnect();
2360
2424
  this.visibilityObserver = undefined;
2361
2425
  }
@@ -2391,6 +2455,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2391
2455
  const groupGrowable = new Array(wordGroups.length).fill(false);
2392
2456
  const groupGlowing = new Array(wordGroups.length).fill(false);
2393
2457
  const groupCharRise = new Array(wordGroups.length).fill(false);
2458
+ const groupCharDrag = new Array(wordGroups.length).fill(false);
2394
2459
  const vwFullText = new Array(wordGroups.length).fill('');
2395
2460
  const vwFullDuration = new Array(wordGroups.length).fill(0);
2396
2461
  const vwCharOffset = new Array(wordGroups.length).fill(0);
@@ -2426,28 +2491,42 @@ let AmLyrics$1 = class AmLyrics extends i {
2426
2491
  const isLineSynced = line.isWordSynced === false || line.text.some(s => s.lineSynced);
2427
2492
  let isGrowableVW = canAnimateByChar && wordLen > 0 && wordLen <= 7;
2428
2493
  if (isGrowableVW) {
2429
- if (wordLen < 3) {
2494
+ if (wordLen <= 1) {
2430
2495
  isGrowableVW =
2431
2496
  combinedDuration >= 1050 && combinedDuration >= wordLen * 525;
2432
2497
  }
2498
+ else if (wordLen <= 3) {
2499
+ isGrowableVW =
2500
+ combinedDuration >=
2501
+ SHORT_WORD_GLOW_MIN_DURATION_MS + (wordLen - 2) * 140;
2502
+ }
2433
2503
  else {
2434
2504
  isGrowableVW =
2435
2505
  combinedDuration >= 850 && combinedDuration >= wordLen * 190;
2436
2506
  }
2437
2507
  }
2438
2508
  const hasCharRiseDuration = combinedDuration >= Math.max(700, wordLen * 85);
2509
+ const hasTinyWordDragDuration = wordLen >= 2 &&
2510
+ wordLen <= 3 &&
2511
+ combinedDuration >=
2512
+ Math.max(SHORT_WORD_DRAG_MIN_DURATION_MS, wordLen * 150);
2439
2513
  const hasLongShortWordDuration = wordLen >= 4 && combinedDuration >= Math.max(1300, wordLen * 260);
2440
2514
  const isCharRiseVW = canAnimateByChar &&
2441
2515
  !isLineSynced &&
2442
2516
  !isGrowableVW &&
2443
2517
  ((wordLen >= 8 && hasCharRiseDuration) ||
2444
2518
  (wordLen < 8 && hasLongShortWordDuration));
2519
+ const isCharDragVW = canAnimateByChar &&
2520
+ !isLineSynced &&
2521
+ !isGrowableVW &&
2522
+ hasTinyWordDragDuration;
2445
2523
  const isGlowingVW = isGrowableVW && !isLineSynced;
2446
2524
  let charOff = 0;
2447
2525
  for (let gi = vwStart; gi <= vwEnd; gi += 1) {
2448
2526
  groupGrowable[gi] = isGrowableVW;
2449
2527
  groupGlowing[gi] = isGlowingVW;
2450
2528
  groupCharRise[gi] = isCharRiseVW;
2529
+ groupCharDrag[gi] = isCharDragVW;
2451
2530
  vwFullText[gi] = combinedText;
2452
2531
  vwFullDuration[gi] = combinedDuration;
2453
2532
  vwCharOffset[gi] = charOff;
@@ -2463,6 +2542,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2463
2542
  groupGrowable,
2464
2543
  groupGlowing,
2465
2544
  groupCharRise,
2545
+ groupCharDrag,
2466
2546
  vwFullText,
2467
2547
  vwFullDuration,
2468
2548
  vwCharOffset,
@@ -2482,48 +2562,97 @@ let AmLyrics$1 = class AmLyrics extends i {
2482
2562
  return;
2483
2563
  const computedStyle = getComputedStyle(referenceSyllable);
2484
2564
  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)
2565
+ const charTimedWords = Array.from(this.shadowRoot.querySelectorAll('.lyrics-word.growable, .lyrics-word.char-rise, .lyrics-word.char-drag'));
2566
+ if (charTimedWords.length === 0)
2488
2567
  return;
2489
- charTimedWords.forEach((wordSpan) => {
2490
- const syllableWraps = wordSpan.querySelectorAll('.lyrics-syllable-wrap');
2491
- // Flatten syllables
2568
+ const wordsByVirtualId = new Map();
2569
+ charTimedWords.forEach((wordSpan, index) => {
2570
+ const virtualWordId = wordSpan.dataset.virtualWordId || `word-${index}`;
2571
+ const words = wordsByVirtualId.get(virtualWordId);
2572
+ if (words) {
2573
+ words.push(wordSpan);
2574
+ }
2575
+ else {
2576
+ wordsByVirtualId.set(virtualWordId, [wordSpan]);
2577
+ }
2578
+ });
2579
+ wordsByVirtualId.forEach(wordSpans => {
2492
2580
  const syllables = [];
2493
- syllableWraps.forEach((wrap) => {
2494
- const syl = wrap.querySelector('.lyrics-syllable');
2495
- if (syl)
2496
- syllables.push(syl);
2581
+ wordSpans.forEach(wordSpan => {
2582
+ const syllableWraps = wordSpan.querySelectorAll('.lyrics-syllable-wrap');
2583
+ syllableWraps.forEach(wrap => {
2584
+ const syl = wrap.querySelector('.lyrics-syllable');
2585
+ if (syl)
2586
+ syllables.push(syl);
2587
+ });
2497
2588
  });
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);
2589
+ const charSpans = syllables.flatMap(syl => {
2590
+ const spans = Array.from(syl.querySelectorAll('.char'));
2591
+ const target = syl;
2592
+ target._cachedCharSpans = spans;
2593
+ return spans;
2594
+ });
2595
+ if (charSpans.length === 0)
2596
+ return;
2597
+ wordSpans.forEach(wordSpan => {
2598
+ const target = wordSpan;
2599
+ target._cachedVirtualWordElements = wordSpans;
2600
+ target._cachedVirtualWordCharSpans = charSpans;
2601
+ });
2602
+ const syllableEntries = syllables.map(syl => {
2603
+ const spans = syl._cachedCharSpans;
2604
+ const charWidths = spans.map(span => this._getTextWidth(span.textContent || '', font));
2605
+ const totalWidth = charWidths.reduce((a, b) => a + b, 0);
2606
+ return {
2607
+ syl,
2608
+ spans,
2609
+ charWidths,
2610
+ totalWidth,
2611
+ start: parseFloat(syl.getAttribute('data-start-time') || ''),
2612
+ end: parseFloat(syl.getAttribute('data-end-time') || ''),
2613
+ };
2614
+ });
2615
+ const totalWordWidth = syllableEntries.reduce((total, entry) => total + entry.totalWidth, 0);
2616
+ if (totalWordWidth <= 0)
2617
+ return;
2618
+ const virtualWordStart = Math.min(...syllableEntries
2619
+ .map(entry => entry.start)
2620
+ .filter(start => Number.isFinite(start)));
2621
+ const virtualWordEnd = Math.max(...syllableEntries
2622
+ .map(entry => entry.end)
2623
+ .filter(end => Number.isFinite(end)));
2624
+ const virtualWordDuration = virtualWordEnd - virtualWordStart;
2625
+ const hasTimedSyllables = Number.isFinite(virtualWordStart) &&
2626
+ Number.isFinite(virtualWordEnd) &&
2627
+ virtualWordDuration > 0;
2628
+ let cumulativeCharWidth = 0;
2629
+ syllableEntries.forEach(entry => {
2630
+ let cumulativeSyllableWidth = 0;
2631
+ const syllableDuration = entry.end - entry.start;
2632
+ const useSyllableTiming = hasTimedSyllables &&
2633
+ Number.isFinite(entry.start) &&
2634
+ Number.isFinite(entry.end) &&
2635
+ syllableDuration > 0 &&
2636
+ entry.totalWidth > 0;
2637
+ entry.spans.forEach((span, index) => {
2638
+ const charWidth = entry.charWidths[index];
2639
+ let startPercent = cumulativeCharWidth / totalWordWidth;
2640
+ let durationPercent = charWidth / totalWordWidth;
2641
+ if (useSyllableTiming) {
2642
+ const charStartMs = entry.start -
2643
+ virtualWordStart +
2644
+ (cumulativeSyllableWidth / entry.totalWidth) * syllableDuration;
2645
+ const charDurationMs = (charWidth / entry.totalWidth) * syllableDuration;
2646
+ startPercent = AmLyrics.clamp(charStartMs / virtualWordDuration, 0, 1);
2647
+ durationPercent = AmLyrics.clamp(charDurationMs / virtualWordDuration, 0, 1);
2525
2648
  }
2649
+ const target = span;
2650
+ target.dataset.wipeStart = startPercent.toFixed(4);
2651
+ target.dataset.wipeDuration = durationPercent.toFixed(4);
2652
+ target.style.setProperty('--word-wipe-width', `${totalWordWidth}px`);
2653
+ target.style.setProperty('--char-wipe-position', `${-cumulativeCharWidth}px`);
2526
2654
  cumulativeCharWidth += charWidth;
2655
+ cumulativeSyllableWidth += charWidth;
2527
2656
  });
2528
2657
  });
2529
2658
  });
@@ -2531,6 +2660,33 @@ let AmLyrics$1 = class AmLyrics extends i {
2531
2660
  static arraysEqual(a, b) {
2532
2661
  return a.length === b.length && a.every((val, i) => val === b[i]);
2533
2662
  }
2663
+ static isLineSyncedLine(line) {
2664
+ if (!line)
2665
+ return false;
2666
+ return line.isWordSynced === false || line.text.some(s => s.lineSynced);
2667
+ }
2668
+ getLineHighlightEndTime(index) {
2669
+ if (!this.lyrics)
2670
+ return 0;
2671
+ const line = this.lyrics[index];
2672
+ if (!line)
2673
+ return 0;
2674
+ const rawEnd = Math.max(line.endtime, line.timestamp);
2675
+ const nextLine = this.lyrics[index + 1];
2676
+ if (!nextLine || nextLine.timestamp <= line.timestamp) {
2677
+ return rawEnd > line.timestamp ? rawEnd + 200 : rawEnd;
2678
+ }
2679
+ if (rawEnd > line.timestamp) {
2680
+ if (nextLine.timestamp < rawEnd) {
2681
+ return rawEnd;
2682
+ }
2683
+ const gapToNext = nextLine.timestamp - rawEnd;
2684
+ if (gapToNext >= INSTRUMENTAL_THRESHOLD_MS) {
2685
+ return rawEnd;
2686
+ }
2687
+ }
2688
+ return nextLine.timestamp;
2689
+ }
2534
2690
  static getLineIndexFromElement(lineElement) {
2535
2691
  if (!lineElement)
2536
2692
  return null;
@@ -2561,6 +2717,26 @@ let AmLyrics$1 = class AmLyrics extends i {
2561
2717
  }
2562
2718
  this.preActiveLineElements = keptLines;
2563
2719
  }
2720
+ setBackgroundExpandedLine(lineElement) {
2721
+ const target = lineElement &&
2722
+ !lineElement.classList.contains('lyrics-gap') &&
2723
+ lineElement.querySelector('.background-vocal-container')
2724
+ ? lineElement
2725
+ : null;
2726
+ if (this.backgroundExpandedLine === target) {
2727
+ if (target && !target.classList.contains('bg-expanded')) {
2728
+ target.classList.add('bg-expanded');
2729
+ }
2730
+ return;
2731
+ }
2732
+ this.backgroundExpandedLine?.classList.remove('bg-expanded');
2733
+ this.backgroundExpandedLine = target;
2734
+ target?.classList.add('bg-expanded');
2735
+ }
2736
+ clearBackgroundExpandedLine() {
2737
+ this.backgroundExpandedLine?.classList.remove('bg-expanded');
2738
+ this.backgroundExpandedLine = null;
2739
+ }
2564
2740
  getPrimaryActiveLineIndex(activeIndices) {
2565
2741
  if (activeIndices.length === 0)
2566
2742
  return null;
@@ -2750,9 +2926,10 @@ let AmLyrics$1 = class AmLyrics extends i {
2750
2926
  const activeLines = [];
2751
2927
  for (let i = 0; i < this.lyrics.length; i += 1) {
2752
2928
  const line = this.lyrics[i];
2929
+ const highlightEndTime = this.getLineHighlightEndTime(i);
2753
2930
  if (line.timestamp > time)
2754
2931
  break;
2755
- if (time >= line.timestamp && time < line.endtime) {
2932
+ if (time >= line.timestamp && time < highlightEndTime) {
2756
2933
  activeLines.push(i);
2757
2934
  }
2758
2935
  }
@@ -2977,6 +3154,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2977
3154
  this.lastPrimaryActiveLine = null;
2978
3155
  this.activeLineIds.clear();
2979
3156
  this.animatingLines = [];
3157
+ this.setBackgroundExpandedLine(null);
2980
3158
  // Find the clicked line element and scroll to it with forceScroll (like YouLyPlus)
2981
3159
  // Timestamps are already in milliseconds — match the data-start-time attribute directly
2982
3160
  const clickedLineElement = this.lyricsContainer?.querySelector(`.lyrics-line[data-start-time="${line.text[0]?.timestamp || 0}"]`);
@@ -2993,6 +3171,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2993
3171
  this.isClickSeeking = false;
2994
3172
  }, 800);
2995
3173
  this.scrollToActiveLineYouLy(clickedLineElement, true);
3174
+ this.setBackgroundExpandedLine(clickedLineElement);
2996
3175
  }
2997
3176
  const event = new CustomEvent('line-click', {
2998
3177
  detail: {
@@ -3338,30 +3517,256 @@ let AmLyrics$1 = class AmLyrics extends i {
3338
3517
  }
3339
3518
  /**
3340
3519
  * Update syllable highlight animation - apply CSS wipe animation
3341
- * (Exact copy from YouLyPlus _updateSyllableAnimation)
3342
3520
  */
3521
+ static clamp(value, min, max) {
3522
+ return Math.min(max, Math.max(min, value));
3523
+ }
3524
+ static getVisibleCharacterCount(element) {
3525
+ const attrLength = parseFloat(element.getAttribute('data-word-length') || '');
3526
+ if (Number.isFinite(attrLength) && attrLength > 0)
3527
+ return attrLength;
3528
+ return (element.textContent || '').replace(/\s/g, '').length;
3529
+ }
3530
+ static getLongWordWipeScale(charCount) {
3531
+ if (charCount <= 6)
3532
+ return 1;
3533
+ return (1 +
3534
+ AmLyrics.clamp((charCount - 6) / 10, 0, 1) * LONG_WORD_WIPE_EXTRA_RATIO);
3535
+ }
3536
+ static applyWipeShape(element, charCount) {
3537
+ const extra = AmLyrics.clamp((charCount - 6) / 10, 0, 1) * LONG_WORD_WIPE_EXTRA_EM;
3538
+ const width = BASE_WIPE_GRADIENT_EM + extra;
3539
+ element.style.setProperty('--wipe-gradient-width', `${width.toFixed(3)}em`);
3540
+ element.style.setProperty('--wipe-gradient-half', `${(width / 2).toFixed(3)}em`);
3541
+ }
3542
+ static ensureWordWipeGeometry(charSpans, charCount) {
3543
+ if (charSpans.length === 0)
3544
+ return;
3545
+ const approxWidthCh = Math.max(1, charCount || charSpans.length);
3546
+ charSpans.forEach((span, index) => {
3547
+ if (!span.style.getPropertyValue('--word-wipe-width')) {
3548
+ span.style.setProperty('--word-wipe-width', `${approxWidthCh}ch`);
3549
+ }
3550
+ if (!span.style.getPropertyValue('--char-wipe-position')) {
3551
+ const startPct = Number.parseFloat(span.dataset.wipeStart || `${index / Math.max(1, charSpans.length)}`);
3552
+ span.style.setProperty('--char-wipe-position', `${-(AmLyrics.clamp(startPct, 0, 1) * approxWidthCh)}ch`);
3553
+ }
3554
+ });
3555
+ }
3556
+ static clearPreHighlight(syllable) {
3557
+ const target = syllable;
3558
+ target.classList.remove('pre-highlight');
3559
+ target.style.removeProperty('--pre-wipe-duration');
3560
+ target.style.removeProperty('--pre-wipe-delay');
3561
+ target.style.animation = '';
3562
+ target
3563
+ .querySelectorAll('.pre-wipe-lead')
3564
+ .forEach(element => AmLyrics.clearPreWipeLead(element));
3565
+ }
3566
+ static clearPreWipeLead(element) {
3567
+ element.classList.remove('pre-wipe-lead');
3568
+ element.style.removeProperty('--pre-wipe-duration');
3569
+ element.style.removeProperty('--pre-wipe-delay');
3570
+ }
3571
+ static hasTextBoundaryAfter(syllable) {
3572
+ return /\s$/.test(syllable.textContent || '');
3573
+ }
3574
+ static getSyllableWordIndex(syllable) {
3575
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3576
+ const virtualWordId = wordElement?.dataset.virtualWordId;
3577
+ if (virtualWordId) {
3578
+ return `virtual:${virtualWordId}`;
3579
+ }
3580
+ const virtualWordStart = wordElement?.dataset.virtualWordStart;
3581
+ const virtualWordEnd = wordElement?.dataset.virtualWordEnd;
3582
+ if (virtualWordStart || virtualWordEnd) {
3583
+ return `virtual:${virtualWordStart || ''}:${virtualWordEnd || ''}`;
3584
+ }
3585
+ return (syllable.getAttribute('data-word-index') ||
3586
+ syllable.getAttribute('data-syllable-index') ||
3587
+ '');
3588
+ }
3589
+ static getNextWordSyllable(syllables, index) {
3590
+ const current = syllables[index];
3591
+ const currentWordIndex = AmLyrics.getSyllableWordIndex(current);
3592
+ const previousSyllable = current;
3593
+ for (let i = index + 1; i < syllables.length; i += 1) {
3594
+ const candidate = syllables[i];
3595
+ if (candidate.classList.contains('transliteration')) {
3596
+ // eslint-disable-next-line no-continue
3597
+ continue;
3598
+ }
3599
+ const candidateWordIndex = AmLyrics.getSyllableWordIndex(candidate);
3600
+ if (candidateWordIndex === currentWordIndex ||
3601
+ !AmLyrics.hasTextBoundaryAfter(previousSyllable)) {
3602
+ return null;
3603
+ }
3604
+ return candidate;
3605
+ }
3606
+ return null;
3607
+ }
3608
+ static getPreviousNonTransliterationSyllable(syllables, index) {
3609
+ for (let i = index - 1; i >= 0; i -= 1) {
3610
+ const candidate = syllables[i];
3611
+ if (!candidate.classList.contains('transliteration')) {
3612
+ return candidate;
3613
+ }
3614
+ }
3615
+ return null;
3616
+ }
3617
+ static getRenderedWordSyllables(syllable) {
3618
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3619
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
3620
+ const wordSyllables = wordElements.flatMap(element => Array.from(element.querySelectorAll('.lyrics-syllable')));
3621
+ return wordSyllables.filter(wordSyllable => !wordSyllable.classList.contains('transliteration'));
3622
+ }
3623
+ static getWordElementForSyllable(syllable) {
3624
+ return syllable.parentElement?.parentElement;
3625
+ }
3626
+ static getWordPreWipeKey(syllable) {
3627
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3628
+ return (wordElement?.dataset.virtualWordId ||
3629
+ `${syllable.getAttribute('data-start-time') || ''}:${AmLyrics.getSyllableWordIndex(syllable)}`);
3630
+ }
3631
+ static isPreWipeArmed(syllable) {
3632
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
3633
+ const target = wordElement;
3634
+ return Boolean(target?._wordPreWipeKey === AmLyrics.getWordPreWipeKey(syllable));
3635
+ }
3636
+ static applyWordPreWipe(nextSyllable, wordSyllables, currentTimeMs, preWipeStartMs, preWipeDurationMs) {
3637
+ if (AmLyrics.isPreWipeArmed(nextSyllable))
3638
+ return;
3639
+ const wordElement = AmLyrics.getWordElementForSyllable(nextSyllable);
3640
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
3641
+ const charSpans = AmLyrics.getCachedVirtualWordCharSpans(wordElement, []);
3642
+ const elapsedPreWipe = currentTimeMs - preWipeStartMs;
3643
+ const charCount = charSpans.length ||
3644
+ wordSyllables.reduce((total, wordSyllable) => total + AmLyrics.getVisibleCharacterCount(wordSyllable), 0) ||
3645
+ AmLyrics.getVisibleCharacterCount(nextSyllable);
3646
+ AmLyrics.ensureWordWipeGeometry(charSpans, charCount);
3647
+ const leadChar = charSpans[0];
3648
+ const leadCharSyllable = leadChar?.closest('.lyrics-syllable');
3649
+ const preWipeSyllable = leadCharSyllable || wordSyllables[0] || nextSyllable;
3650
+ AmLyrics.applyWipeShape(preWipeSyllable, charCount);
3651
+ preWipeSyllable.style.setProperty('--pre-wipe-duration', `${preWipeDurationMs}ms`);
3652
+ preWipeSyllable.style.setProperty('--pre-wipe-delay', `${-elapsedPreWipe}ms`);
3653
+ preWipeSyllable.classList.add('pre-highlight');
3654
+ let leadSpans = charSpans;
3655
+ if (leadSpans.length === 0 && leadChar) {
3656
+ leadSpans = [leadChar];
3657
+ }
3658
+ leadSpans.forEach(span => {
3659
+ AmLyrics.applyWipeShape(span, charCount);
3660
+ span.style.setProperty('--pre-wipe-duration', `${preWipeDurationMs}ms`);
3661
+ span.style.setProperty('--pre-wipe-delay', `${-elapsedPreWipe}ms`);
3662
+ span.classList.add('pre-wipe-lead');
3663
+ });
3664
+ wordElements.forEach(element => {
3665
+ const target = element;
3666
+ target._wordPreWipeKey = AmLyrics.getWordPreWipeKey(nextSyllable);
3667
+ });
3668
+ }
3669
+ static maybePreWipeNextWord(syllables, index, currentTimeMs, currentEndTimeMs) {
3670
+ const syllable = syllables[index];
3671
+ if (syllable.classList.contains('line-synced') ||
3672
+ syllable.classList.contains('transliteration') ||
3673
+ syllable.closest('.lyrics-gap')) {
3674
+ return;
3675
+ }
3676
+ const currentWordReady = syllable.classList.contains('finished') ||
3677
+ currentTimeMs >= currentEndTimeMs - WORD_PRE_WIPE_HANDOFF_LEAD_MS;
3678
+ if (!currentWordReady) {
3679
+ return;
3680
+ }
3681
+ const nextSyllable = AmLyrics.getNextWordSyllable(syllables, index);
3682
+ if (!nextSyllable ||
3683
+ nextSyllable.classList.contains('line-synced') ||
3684
+ nextSyllable.classList.contains('transliteration') ||
3685
+ nextSyllable.closest('.lyrics-gap') ||
3686
+ nextSyllable.classList.contains('highlight') ||
3687
+ nextSyllable.classList.contains('finished')) {
3688
+ return;
3689
+ }
3690
+ const nextStartTimeMs = nextSyllable._cachedStartTime;
3691
+ if (!Number.isFinite(nextStartTimeMs))
3692
+ return;
3693
+ const gapMs = nextStartTimeMs - currentEndTimeMs;
3694
+ if (gapMs > NEXT_WORD_PRE_WIPE_MAX_GAP_MS || gapMs < -50) {
3695
+ return;
3696
+ }
3697
+ const nextWordSyllables = AmLyrics.getRenderedWordSyllables(nextSyllable);
3698
+ const preWipeSyllables = nextWordSyllables.length > 0 ? nextWordSyllables : [nextSyllable];
3699
+ const wordElement = AmLyrics.getWordElementForSyllable(nextSyllable);
3700
+ const wordCharSpans = AmLyrics.getCachedVirtualWordCharSpans(wordElement, []);
3701
+ const charCount = wordCharSpans.length ||
3702
+ preWipeSyllables.reduce((total, wordSyllable) => total + AmLyrics.getVisibleCharacterCount(wordSyllable), 0);
3703
+ if (charCount <= 0)
3704
+ return;
3705
+ const preWipeDuration = AmLyrics.clamp(64 + charCount * 9, NEXT_WORD_PRE_WIPE_MIN_DURATION_MS, NEXT_WORD_PRE_WIPE_MAX_DURATION_MS);
3706
+ const preWipeStart = Math.max(nextStartTimeMs - preWipeDuration, currentEndTimeMs - WORD_PRE_WIPE_HANDOFF_LEAD_MS);
3707
+ if (currentTimeMs < preWipeStart || currentTimeMs >= nextStartTimeMs) {
3708
+ return;
3709
+ }
3710
+ AmLyrics.applyWordPreWipe(nextSyllable, preWipeSyllables, currentTimeMs, preWipeStart, preWipeDuration);
3711
+ }
3712
+ static getCachedCharSpans(element) {
3713
+ const cacheTarget = element;
3714
+ if (!cacheTarget._cachedCharSpans) {
3715
+ cacheTarget._cachedCharSpans = Array.from(element.querySelectorAll('span.char'));
3716
+ }
3717
+ return cacheTarget._cachedCharSpans;
3718
+ }
3719
+ static getCachedVirtualWordElements(wordElement) {
3720
+ if (!wordElement)
3721
+ return [];
3722
+ const cacheTarget = wordElement;
3723
+ if (cacheTarget._cachedVirtualWordElements) {
3724
+ return cacheTarget._cachedVirtualWordElements;
3725
+ }
3726
+ const { virtualWordId } = wordElement.dataset;
3727
+ let wordElements = [wordElement];
3728
+ if (virtualWordId && wordElement.parentElement) {
3729
+ wordElements = Array.from(wordElement.parentElement.querySelectorAll('.lyrics-word')).filter(el => el.dataset.virtualWordId === virtualWordId);
3730
+ }
3731
+ wordElements.forEach(element => {
3732
+ const target = element;
3733
+ target._cachedVirtualWordElements = wordElements;
3734
+ });
3735
+ return wordElements;
3736
+ }
3737
+ static getCachedVirtualWordCharSpans(wordElement, fallbackCharSpans) {
3738
+ if (!wordElement)
3739
+ return fallbackCharSpans;
3740
+ const cacheTarget = wordElement;
3741
+ if (cacheTarget._cachedVirtualWordCharSpans) {
3742
+ return cacheTarget._cachedVirtualWordCharSpans;
3743
+ }
3744
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
3745
+ const charSpans = wordElements.flatMap(word => Array.from(word.querySelectorAll('span.char')));
3746
+ const result = charSpans.length > 0 ? charSpans : fallbackCharSpans;
3747
+ wordElements.forEach(element => {
3748
+ const target = element;
3749
+ target._cachedVirtualWordCharSpans = result;
3750
+ });
3751
+ return result;
3752
+ }
3343
3753
  static updateSyllableAnimation(syllable, elapsedTimeMs = 0) {
3344
3754
  if (syllable.classList.contains('highlight'))
3345
3755
  return;
3346
3756
  const { classList } = syllable;
3757
+ const hadPreHighlight = classList.contains('pre-highlight');
3347
3758
  const isRTL = classList.contains('rtl-text');
3348
- const charSpans = Array.from(syllable.querySelectorAll('span.char'));
3759
+ const charSpans = AmLyrics.getCachedCharSpans(syllable);
3349
3760
  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');
3761
+ const typedWordElement = wordElement;
3762
+ const allWordElements = AmLyrics.getCachedVirtualWordElements(typedWordElement);
3763
+ const allWordCharSpans = AmLyrics.getCachedVirtualWordCharSpans(typedWordElement, charSpans);
3764
+ const isGrowable = typedWordElement?.classList.contains('growable');
3765
+ const isCharRise = typedWordElement?.classList.contains('char-rise');
3766
+ const isCharDrag = typedWordElement?.classList.contains('char-drag');
3362
3767
  const isFirstSyllable = syllable.getAttribute('data-syllable-index') === '0';
3363
3768
  const syllableStartMs = parseFloat(syllable.getAttribute('data-start-time') || '0');
3364
- const virtualWordStartMs = parseFloat(wordElement?.dataset.virtualWordStart || '');
3769
+ const virtualWordStartMs = parseFloat(typedWordElement?.dataset.virtualWordStart || '');
3365
3770
  const isFirstInVirtualWord = isFirstSyllable &&
3366
3771
  (!Number.isFinite(virtualWordStartMs) ||
3367
3772
  Math.abs(syllableStartMs - virtualWordStartMs) < 0.5);
@@ -3372,7 +3777,10 @@ let AmLyrics$1 = class AmLyrics extends i {
3372
3777
  const wordDurationMs = parseFloat(syllable.getAttribute('data-word-duration') ||
3373
3778
  syllable.getAttribute('data-duration') ||
3374
3779
  '0') || syllableDurationMs;
3375
- // Use a Map to collect animations like YouLyPlus
3780
+ const wordElapsedTimeMs = Number.isFinite(virtualWordStartMs)
3781
+ ? elapsedTimeMs + (syllableStartMs - virtualWordStartMs)
3782
+ : elapsedTimeMs;
3783
+ const charWipeDurationMs = Math.max(wordDurationMs, syllableDurationMs);
3376
3784
  const charAnimationsMap = new Map();
3377
3785
  const styleUpdates = [];
3378
3786
  // Step 1: Grow Pass
@@ -3420,16 +3828,60 @@ let AmLyrics$1 = class AmLyrics extends i {
3420
3828
  charAnimationsMap.set(span, `rise-char ${riseDurationMs}ms ease-in-out ${riseDelay}ms forwards`);
3421
3829
  });
3422
3830
  }
3831
+ if (isCharDrag && isFirstInVirtualWord && allWordCharSpans.length > 0) {
3832
+ const finalDuration = Math.max(wordDurationMs, syllableDurationMs);
3833
+ const baseDelayPerChar = AmLyrics.clamp(finalDuration * 0.15, 64, 118);
3834
+ const dragDurationMs = AmLyrics.clamp(finalDuration * 0.82, 560, 900);
3835
+ allWordCharSpans.forEach(span => {
3836
+ const charIndex = parseFloat(span.dataset.syllableCharIndex || '0');
3837
+ const dragDelay = baseDelayPerChar * charIndex;
3838
+ charAnimationsMap.set(span, `drag-char ${dragDurationMs}ms ease ${dragDelay}ms forwards`);
3839
+ });
3840
+ }
3423
3841
  // Step 2: Wipe Pass
3424
3842
  if (charSpans.length > 0) {
3425
- charSpans.forEach((span, charIndex) => {
3843
+ const wipeCharCount = allWordCharSpans.length ||
3844
+ charSpans.length ||
3845
+ AmLyrics.getVisibleCharacterCount(syllable);
3846
+ const wipeScale = AmLyrics.getLongWordWipeScale(wipeCharCount);
3847
+ AmLyrics.applyWipeShape(syllable, wipeCharCount);
3848
+ AmLyrics.ensureWordWipeGeometry(allWordCharSpans, wipeCharCount);
3849
+ allWordCharSpans.forEach(span => AmLyrics.applyWipeShape(span, wipeCharCount));
3850
+ const hasWordLevelWipe = !isFirstInVirtualWord &&
3851
+ (Boolean(typedWordElement?._wordWipeStarted) ||
3852
+ allWordCharSpans.some(span => span.style.animation.includes('wipe')));
3853
+ let charSpansToAnimate = charSpans;
3854
+ if (isFirstInVirtualWord) {
3855
+ charSpansToAnimate = allWordCharSpans;
3856
+ }
3857
+ else if (hasWordLevelWipe) {
3858
+ charSpansToAnimate = [];
3859
+ }
3860
+ if (charSpansToAnimate.length > 0 && allWordElements.length > 0) {
3861
+ allWordElements.forEach(element => {
3862
+ const target = element;
3863
+ target._wordWipeStarted = true;
3864
+ target._wordPreWipeKey = undefined;
3865
+ });
3866
+ }
3867
+ charSpansToAnimate.forEach((span, charIndex) => {
3426
3868
  const startPct = parseFloat(span.dataset.wipeStart || '0');
3427
3869
  const durationPct = parseFloat(span.dataset.wipeDuration || '0');
3428
- const wipeDelay = syllableDurationMs * startPct - elapsedTimeMs;
3429
- const wipeDuration = syllableDurationMs * durationPct;
3430
- const useStartAnimation = isFirstInContainer && charIndex === 0;
3870
+ const globalCharIndex = parseFloat(span.dataset.syllableCharIndex || `${charIndex}`);
3871
+ const hadCharPreWipe = span.classList.contains('pre-wipe-lead') ||
3872
+ (hadPreHighlight && globalCharIndex === 0);
3873
+ const charStartMs = charWipeDurationMs * startPct;
3874
+ const remainingWordWipeMs = Math.max(0, charWipeDurationMs - charStartMs);
3875
+ let wipeDelay = charStartMs - wordElapsedTimeMs;
3876
+ let wipeDuration = Math.min(charWipeDurationMs * durationPct * wipeScale, remainingWordWipeMs);
3877
+ const useStartAnimation = isFirstInContainer && globalCharIndex === 0 && !hadCharPreWipe;
3431
3878
  let charWipeAnimation = 'wipe';
3432
- if (useStartAnimation) {
3879
+ if (hadCharPreWipe) {
3880
+ charWipeAnimation = 'wipe-word-from-pre';
3881
+ wipeDelay = -wordElapsedTimeMs;
3882
+ wipeDuration = charWipeDurationMs;
3883
+ }
3884
+ else if (useStartAnimation) {
3433
3885
  charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
3434
3886
  }
3435
3887
  else {
@@ -3439,22 +3891,13 @@ let AmLyrics$1 = class AmLyrics extends i {
3439
3891
  const animationParts = [];
3440
3892
  if (existingAnimation &&
3441
3893
  (existingAnimation.includes('grow-dynamic') ||
3442
- existingAnimation.includes('rise-char'))) {
3894
+ existingAnimation.includes('rise-char') ||
3895
+ existingAnimation.includes('drag-char'))) {
3443
3896
  animationParts.push(existingAnimation.split(',')[0].trim());
3444
3897
  }
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;
3452
- if (preWipeDuration >= 16) {
3453
- animationParts.push(`pre-wipe-char ${preWipeDuration}ms linear ${animDelay}ms none`);
3454
- }
3455
- }
3456
3898
  if (wipeDuration > 0) {
3457
- animationParts.push(`${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`);
3899
+ const wipeFillMode = hadCharPreWipe ? 'both' : 'forwards';
3900
+ animationParts.push(`${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms ${wipeFillMode}`);
3458
3901
  }
3459
3902
  if (animationParts.length > 0) {
3460
3903
  charAnimationsMap.set(span, animationParts.join(', '));
@@ -3464,9 +3907,15 @@ let AmLyrics$1 = class AmLyrics extends i {
3464
3907
  else {
3465
3908
  // Syllable-level wipe for regular (non-growable) words without chars
3466
3909
  const wipeRatio = parseFloat(syllable.getAttribute('data-wipe-ratio') || '1');
3467
- const visualDuration = syllableDurationMs * wipeRatio;
3910
+ const wipeCharCount = AmLyrics.getVisibleCharacterCount(syllable);
3911
+ const wipeScale = AmLyrics.getLongWordWipeScale(wipeCharCount);
3912
+ const visualDuration = syllableDurationMs * wipeRatio * wipeScale;
3913
+ AmLyrics.applyWipeShape(syllable, wipeCharCount);
3468
3914
  let wipeAnimation = 'wipe';
3469
- if (isFirstInContainer) {
3915
+ if (hadPreHighlight) {
3916
+ wipeAnimation = isRTL ? 'wipe-from-pre-rtl' : 'wipe-from-pre';
3917
+ }
3918
+ else if (isFirstInContainer) {
3470
3919
  wipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
3471
3920
  }
3472
3921
  else {
@@ -3479,16 +3928,25 @@ let AmLyrics$1 = class AmLyrics extends i {
3479
3928
  syllable.style.animation = `${currentWipeAnimation} ${visualDuration}ms ${isGap ? 'ease-out' : 'linear'} ${-elapsedTimeMs}ms forwards`;
3480
3929
  }
3481
3930
  // --- WRITE PHASE ---
3931
+ if (allWordElements.length > 0) {
3932
+ allWordElements.forEach(element => {
3933
+ const target = element;
3934
+ target._wordPreWipeKey = undefined;
3935
+ });
3936
+ }
3482
3937
  classList.remove('pre-highlight');
3483
3938
  classList.add('highlight');
3939
+ allWordCharSpans.forEach(span => AmLyrics.clearPreWipeLead(span));
3940
+ // Apply keyframe variables before assigning animation strings so the
3941
+ // first painted frame never uses fallback transform values.
3942
+ for (const update of styleUpdates) {
3943
+ update.element.style.setProperty(update.property, update.value);
3944
+ }
3484
3945
  for (const [span, animationString] of charAnimationsMap.entries()) {
3485
3946
  span.style.willChange = 'transform';
3947
+ span.style.removeProperty('background-color');
3486
3948
  span.style.animation = animationString;
3487
3949
  }
3488
- // Apply style updates
3489
- for (const update of styleUpdates) {
3490
- update.element.style.setProperty(update.property, update.value);
3491
- }
3492
3950
  }
3493
3951
  /**
3494
3952
  * Reset syllable animation state
@@ -3512,10 +3970,19 @@ let AmLyrics$1 = class AmLyrics extends i {
3512
3970
  el.style.animation = '';
3513
3971
  el.style.transition = 'none';
3514
3972
  el.style.backgroundColor = 'var(--lyplus-text-secondary)';
3973
+ AmLyrics.clearPreWipeLead(el);
3515
3974
  }
3516
3975
  // Immediately remove all state classes
3517
3976
  syllable.classList.remove('highlight', 'finished', 'pre-highlight', 'cleanup');
3518
3977
  }
3978
+ static resetWordAnimationState(line) {
3979
+ const wordElements = line.querySelectorAll('.lyrics-word');
3980
+ wordElements.forEach(wordElement => {
3981
+ const target = wordElement;
3982
+ target._wordPreWipeKey = undefined;
3983
+ target._wordWipeStarted = false;
3984
+ });
3985
+ }
3519
3986
  /**
3520
3987
  * Reset all syllables in a line — batches deferred cleanup into a single rAF
3521
3988
  */
@@ -3523,6 +3990,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3523
3990
  if (!line)
3524
3991
  return;
3525
3992
  line.classList.remove('persist-highlight');
3993
+ AmLyrics.resetWordAnimationState(line);
3526
3994
  // eslint-disable-next-line no-param-reassign
3527
3995
  line._cachedSyllableElements = null;
3528
3996
  const syllables = line.getElementsByClassName('lyrics-syllable');
@@ -3554,6 +4022,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3554
4022
  if (!line)
3555
4023
  return;
3556
4024
  line.classList.remove('persist-highlight');
4025
+ AmLyrics.resetWordAnimationState(line);
3557
4026
  const syllables = line.getElementsByClassName('lyrics-syllable');
3558
4027
  for (let i = 0; i < syllables.length; i += 1) {
3559
4028
  const s = syllables[i];
@@ -3571,6 +4040,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3571
4040
  el.style.removeProperty('background-color');
3572
4041
  el.style.removeProperty('transition');
3573
4042
  el.style.removeProperty('filter');
4043
+ AmLyrics.clearPreWipeLead(el);
3574
4044
  }
3575
4045
  }
3576
4046
  }
@@ -3607,19 +4077,26 @@ let AmLyrics$1 = class AmLyrics extends i {
3607
4077
  syllable.style.animation = '';
3608
4078
  syllable.style.removeProperty('--pre-wipe-duration');
3609
4079
  syllable.style.removeProperty('--pre-wipe-delay');
4080
+ syllable.style.removeProperty('background-color');
4081
+ AmLyrics.applyWipeShape(syllable, AmLyrics.getVisibleCharacterCount(syllable));
3610
4082
  const chars = syllable.querySelectorAll('span.char');
3611
4083
  for (let ci = 0; ci < chars.length; ci += 1) {
3612
4084
  const charEl = chars[ci];
3613
4085
  const currentAnim = charEl.style.animation || '';
3614
4086
  if (currentAnim.includes('grow-dynamic') ||
3615
- currentAnim.includes('rise-char')) {
4087
+ currentAnim.includes('rise-char') ||
4088
+ currentAnim.includes('drag-char')) {
3616
4089
  const parts = currentAnim.split(',').map(p => p.trim());
3617
- const transformAnim = parts.find(p => p.includes('grow-dynamic') || p.includes('rise-char'));
4090
+ const transformAnim = parts.find(p => p.includes('grow-dynamic') ||
4091
+ p.includes('rise-char') ||
4092
+ p.includes('drag-char'));
3618
4093
  charEl.style.animation = transformAnim || '';
3619
4094
  }
3620
4095
  else {
3621
4096
  charEl.style.animation = '';
3622
4097
  }
4098
+ charEl.style.backgroundColor = 'var(--lyplus-text-primary)';
4099
+ AmLyrics.clearPreWipeLead(charEl);
3623
4100
  }
3624
4101
  }
3625
4102
  }
@@ -3660,14 +4137,16 @@ let AmLyrics$1 = class AmLyrics extends i {
3660
4137
  // Early exit check
3661
4138
  if (!(currentTimeMs < startTime - 1000 && !hasActiveState)) {
3662
4139
  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 = '';
4140
+ // Before the syllable starts, pre-highlight only belongs beside a
4141
+ // previous active word. Once the syllable starts, updateSyllableAnimation
4142
+ // consumes the class so the actual wipe can continue from the pre-wipe
4143
+ // pose instead of restarting from the beginning.
4144
+ if (hasPreHighlight && currentTimeMs < startTime) {
4145
+ const prevSyllable = AmLyrics.getPreviousNonTransliterationSyllable(syllables, i);
4146
+ const previousCarriesHighlight = prevSyllable?.classList.contains('highlight') ||
4147
+ prevSyllable?.classList.contains('finished');
4148
+ if (!previousCarriesHighlight) {
4149
+ AmLyrics.clearPreHighlight(syllable);
3671
4150
  preHighlightReset = true;
3672
4151
  }
3673
4152
  }
@@ -3695,6 +4174,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3695
4174
  // Not yet started
3696
4175
  AmLyrics.resetSyllable(syllable);
3697
4176
  }
4177
+ AmLyrics.maybePreWipeNextWord(syllables, i, currentTimeMs, endTime);
3698
4178
  }
3699
4179
  }
3700
4180
  }
@@ -4004,6 +4484,9 @@ let AmLyrics$1 = class AmLyrics extends i {
4004
4484
  data-end-time="${endTimeMs}"
4005
4485
  data-duration="${durationMs}"
4006
4486
  data-syllable-index="${syllableIndex}"
4487
+ data-word-index="${syllableIndex}"
4488
+ data-word-length="${syllable.text.replace(/\s/g, '')
4489
+ .length}"
4007
4490
  data-wipe-ratio="1"
4008
4491
  >${syllable.text}</span
4009
4492
  >${bgRomanizedText}</span
@@ -4025,13 +4508,13 @@ let AmLyrics$1 = class AmLyrics extends i {
4025
4508
  const groupGrowable = lineData?.groupGrowable ?? [];
4026
4509
  const groupGlowing = lineData?.groupGlowing ?? [];
4027
4510
  const groupCharRise = lineData?.groupCharRise ?? [];
4511
+ const groupCharDrag = lineData?.groupCharDrag ?? [];
4028
4512
  const vwFullText = lineData?.vwFullText ?? [];
4029
4513
  const vwFullDuration = lineData?.vwFullDuration ?? [];
4030
4514
  const vwCharOffset = lineData?.vwCharOffset ?? [];
4031
4515
  const vwStartMs = lineData?.vwStartMs ?? [];
4032
4516
  const vwEndMs = lineData?.vwEndMs ?? [];
4033
4517
  const lineIsRTL = lineData?.lineIsRTL ?? false;
4034
- // Create main vocals using YouLyPlus syllable structure
4035
4518
  const mainVocalElement = b `<p
4036
4519
  class="main-vocal-container ${lineIsRTL ? 'rtl-text' : ''}"
4037
4520
  >
@@ -4039,25 +4522,23 @@ let AmLyrics$1 = class AmLyrics extends i {
4039
4522
  const isGrowable = groupGrowable[groupIdx];
4040
4523
  const isGlowing = groupGlowing[groupIdx];
4041
4524
  const isCharRise = groupCharRise[groupIdx];
4042
- const isAnimatedByChar = isGrowable || isCharRise;
4525
+ const isCharDrag = groupCharDrag[groupIdx];
4526
+ const isAnimatedByChar = isGrowable || isCharRise || isCharDrag;
4043
4527
  const groupLineSynced = group.some(s => s.lineSynced);
4044
4528
  const wordText = isAnimatedByChar ? vwFullText[groupIdx] : '';
4045
4529
  const wordDuration = isAnimatedByChar
4046
4530
  ? vwFullDuration[groupIdx]
4047
4531
  : 0;
4048
- const wordNumChars = wordText.length;
4532
+ const wordNumChars = wordText.replace(/\s/g, '').length;
4049
4533
  const groupCharOffset = isAnimatedByChar
4050
4534
  ? vwCharOffset[groupIdx]
4051
4535
  : 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] : '';
4536
+ const virtualWordId = `${lineIndex}:${vwStartMs[groupIdx]}:${vwEndMs[groupIdx]}`;
4537
+ const virtualWordStart = vwStartMs[groupIdx];
4538
+ const virtualWordEnd = vwEndMs[groupIdx];
4059
4539
  let sylCharAccumulator = 0;
4060
4540
  const groupText = group.map(s => s.text).join('');
4541
+ const visibleWordLength = groupText.replace(/\s/g, '').length;
4061
4542
  const shouldAllowBreak = groupText.trim().length >= 16 ||
4062
4543
  /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(groupText);
4063
4544
  // Calculate dynamic rise duration based on the audio duration of the word
@@ -4069,9 +4550,9 @@ let AmLyrics$1 = class AmLyrics extends i {
4069
4550
  return b `<span
4070
4551
  class="lyrics-word${isGrowable ? ' growable' : ''}${isCharRise
4071
4552
  ? ' char-rise'
4072
- : ''}${isGlowing ? ' glowing' : ''}${shouldAllowBreak
4073
- ? ' allow-break'
4074
- : ''}"
4553
+ : ''}${isCharDrag ? ' char-drag' : ''}${isGlowing
4554
+ ? ' glowing'
4555
+ : ''}${shouldAllowBreak ? ' allow-break' : ''}"
4075
4556
  data-virtual-word-id="${virtualWordId}"
4076
4557
  data-virtual-word-start="${virtualWordStart}"
4077
4558
  data-virtual-word-end="${virtualWordEnd}"
@@ -4098,13 +4579,26 @@ let AmLyrics$1 = class AmLyrics extends i {
4098
4579
  : '';
4099
4580
  let syllableContent = text;
4100
4581
  if (isAnimatedByChar) {
4101
- let charIndexInsideSyllable = 0;
4102
4582
  const numCharsInSyllable = text.replace(/\s/g, '').length || 1;
4583
+ const hasVirtualTiming = wordDuration > 0 && Number.isFinite(virtualWordStart);
4584
+ const syllableStartRatio = hasVirtualTiming
4585
+ ? AmLyrics.clamp((startTimeMs - virtualWordStart) / wordDuration, 0, 1)
4586
+ : 0;
4587
+ const syllableDurationRatio = hasVirtualTiming
4588
+ ? AmLyrics.clamp(durationMs / wordDuration, 0, 1)
4589
+ : 1;
4590
+ let charIndexInsideSyllable = 0;
4103
4591
  syllableContent = b `${text.split('').map(char => {
4104
4592
  if (char === ' ')
4105
4593
  return ' ';
4106
4594
  const charIndexInsideWord = groupCharOffset + sylCharAccumulator;
4107
- const charStartPercentVal = charIndexInsideSyllable / numCharsInSyllable;
4595
+ const localCharIndex = charIndexInsideSyllable;
4596
+ const visibleWordChars = Math.max(1, wordNumChars);
4597
+ const charStartPercentVal = AmLyrics.clamp(syllableStartRatio +
4598
+ (localCharIndex / numCharsInSyllable) *
4599
+ syllableDurationRatio, 0, 1);
4600
+ const charDurationPercentVal = syllableDurationRatio / numCharsInSyllable ||
4601
+ 1 / visibleWordChars;
4108
4602
  sylCharAccumulator += 1;
4109
4603
  charIndexInsideSyllable += 1;
4110
4604
  const minDuration = 400;
@@ -4151,21 +4645,37 @@ let AmLyrics$1 = class AmLyrics extends i {
4151
4645
  const normalizedGrowth = (charMaxScale - 1.0) / 0.1;
4152
4646
  const effectiveDuration = (wordDuration + durationMs * 2) / 3;
4153
4647
  const peakMultiplier = Math.min(1, Math.max(0.3, effectiveDuration / 2000));
4154
- const charTranslateYPeak = -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
4648
+ const baseTranslateYPeak = -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
4155
4649
  const position = (charIndexInsideWord + 0.5) / wordNumChars;
4156
4650
  const horizontalOffset = (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25);
4651
+ const isDragMotion = isCharDrag;
4652
+ let charTranslateYPeak = baseTranslateYPeak;
4653
+ if (isCharRise) {
4654
+ charTranslateYPeak = 0;
4655
+ }
4656
+ else if (isDragMotion) {
4657
+ charTranslateYPeak = -0.78;
4658
+ }
4659
+ let motionHorizontalOffset = horizontalOffset;
4660
+ if (isCharRise) {
4661
+ motionHorizontalOffset = 0;
4662
+ }
4663
+ else if (isDragMotion) {
4664
+ motionHorizontalOffset = 0;
4665
+ }
4157
4666
  return b `<span
4158
4667
  class="char"
4159
4668
  data-char-index="${charIndexInsideWord}"
4160
4669
  data-syllable-char-index="${charIndexInsideWord}"
4161
4670
  data-wipe-start="${charStartPercentVal.toFixed(4)}"
4162
- data-wipe-duration="${(1 / numCharsInSyllable).toFixed(4)}"
4671
+ data-wipe-duration="${charDurationPercentVal.toFixed(4)}"
4163
4672
  data-horizontal-offset="${horizontalOffset.toFixed(2)}"
4164
4673
  data-max-scale="${charMaxScale.toFixed(3)}"
4165
4674
  data-matrix-scale="${(charMaxScale * 0.98).toFixed(3)}"
4166
- data-char-offset-x="${(horizontalOffset * 0.98).toFixed(2)}"
4675
+ data-char-offset-x="${(motionHorizontalOffset * 0.98).toFixed(2)}"
4167
4676
  data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
4168
4677
  data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
4678
+ style="--word-wipe-width: ${visibleWordChars}ch; --char-wipe-position: -${charIndexInsideWord}ch"
4169
4679
  >${char}</span
4170
4680
  >`;
4171
4681
  })}`;
@@ -4183,6 +4693,8 @@ let AmLyrics$1 = class AmLyrics extends i {
4183
4693
  data-duration="${durationMs}"
4184
4694
  data-word-duration="${wordDuration}"
4185
4695
  data-syllable-index="${sylIdx}"
4696
+ data-word-index="${groupIdx}"
4697
+ data-word-length="${visibleWordLength}"
4186
4698
  data-wipe-ratio="1"
4187
4699
  >${syllableContent}</span
4188
4700
  >${romanizedText}</span
@@ -4415,13 +4927,14 @@ let AmLyrics$1 = class AmLyrics extends i {
4415
4927
  <button
4416
4928
  class="download-button source-switch-btn"
4417
4929
  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
4930
  @click=${this.switchSource}
4420
4931
  ?disabled=${this.isFetchingAlternatives}
4421
4932
  >
4422
4933
  <svg
4423
- class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
4424
- style="margin-right: 4px;"
4934
+ class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up ${this
4935
+ .isFetchingAlternatives
4936
+ ? 'is-loading'
4937
+ : ''}"
4425
4938
  xmlns="http://www.w3.org/2000/svg"
4426
4939
  width="12"
4427
4940
  height="12"
@@ -4479,9 +4992,6 @@ let AmLyrics$1 = class AmLyrics extends i {
4479
4992
  }
4480
4993
  };
4481
4994
  AmLyrics$1.styles = i$3 `
4482
- /* ==========================================================================
4483
- YOULYPLUS-INSPIRED STYLING - Design Tokens & Variables
4484
- ========================================================================== */
4485
4995
  :host {
4486
4996
  --lyplus-lyrics-palette: var(
4487
4997
  --am-lyrics-highlight-color,
@@ -4510,6 +5020,8 @@ AmLyrics$1.styles = i$3 `
4510
5020
  --lyplus-blur-amount: 0.07em;
4511
5021
  --lyplus-blur-amount-near: 0.035em;
4512
5022
  --lyplus-fade-gap-timing-function: ease-out;
5023
+ --wipe-gradient-width: 0.75em;
5024
+ --wipe-gradient-half: 0.375em;
4513
5025
 
4514
5026
  --lyrics-scroll-padding-top: 25%;
4515
5027
 
@@ -4537,6 +5049,9 @@ AmLyrics$1.styles = i$3 `
4537
5049
  max-height: 100vh;
4538
5050
  overflow-y: auto;
4539
5051
  -webkit-overflow-scrolling: touch;
5052
+ -webkit-touch-callout: none;
5053
+ -webkit-user-select: none;
5054
+ user-select: none;
4540
5055
  box-sizing: border-box;
4541
5056
  scrollbar-width: none;
4542
5057
  overflow-anchor: none;
@@ -4666,7 +5181,7 @@ AmLyrics$1.styles = i$3 `
4666
5181
  .lyrics-line.bg-expanded .background-vocal-container {
4667
5182
  max-height: 4em;
4668
5183
  opacity: 1;
4669
- will-change: max-height, opacity;
5184
+ will-change: opacity;
4670
5185
  }
4671
5186
 
4672
5187
  .lyrics-line.bg-expanded .background-vocal-wrap {
@@ -4818,11 +5333,22 @@ AmLyrics$1.styles = i$3 `
4818
5333
  white-space: nowrap;
4819
5334
  }
4820
5335
 
5336
+ .lyrics-word.char-drag {
5337
+ display: inline-block;
5338
+ vertical-align: baseline;
5339
+ white-space: nowrap;
5340
+ }
5341
+
4821
5342
  .lyrics-word.char-rise.allow-break {
4822
5343
  display: inline;
4823
5344
  white-space: normal;
4824
5345
  }
4825
5346
 
5347
+ .lyrics-word.char-drag.allow-break {
5348
+ display: inline;
5349
+ white-space: normal;
5350
+ }
5351
+
4826
5352
  .lyrics-syllable-wrap {
4827
5353
  display: inline;
4828
5354
  }
@@ -4877,44 +5403,30 @@ AmLyrics$1.styles = i$3 `
4877
5403
  .lyrics-line.active:not(.lyrics-gap)
4878
5404
  .lyrics-syllable.pre-highlight.no-chars {
4879
5405
  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%;
5406
+ background-image: linear-gradient(
5407
+ 90deg,
5408
+ var(--lyplus-text-primary, #fff) 0%,
5409
+ var(--lyplus-text-primary, #fff)
5410
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5411
+ #0000 100%
5412
+ );
5413
+ background-size: 0% 100%;
5414
+ background-position: left;
4898
5415
  }
4899
5416
 
4900
5417
  .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.highlight.rtl-text,
4901
5418
  .lyrics-line.active:not(.lyrics-gap)
4902
5419
  .lyrics-syllable.pre-highlight.rtl-text {
4903
5420
  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%;
5421
+ background-image: linear-gradient(
5422
+ -90deg,
5423
+ var(--lyplus-text-primary) 0%,
5424
+ var(--lyplus-text-primary)
5425
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5426
+ transparent 100%
5427
+ );
5428
+ background-size: 0% 100%;
5429
+ background-position: right 0%;
4918
5430
  }
4919
5431
 
4920
5432
  /* Background vocals: muted gray wipe instead of white.
@@ -4931,18 +5443,13 @@ AmLyrics$1.styles = i$3 `
4931
5443
  .lyrics-line.pre-active
4932
5444
  .background-vocal-container
4933
5445
  .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
- );
5446
+ background-image: linear-gradient(
5447
+ 90deg,
5448
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 0%,
5449
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888)
5450
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5451
+ #0000 100%
5452
+ );
4946
5453
  }
4947
5454
 
4948
5455
  .lyrics-line.active
@@ -4957,17 +5464,13 @@ AmLyrics$1.styles = i$3 `
4957
5464
  .lyrics-line.pre-active
4958
5465
  .background-vocal-container
4959
5466
  .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
- );
5467
+ background-image: linear-gradient(
5468
+ -90deg,
5469
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 0%,
5470
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888)
5471
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5472
+ transparent 100%
5473
+ );
4971
5474
  }
4972
5475
 
4973
5476
  /* Non-growable words float up with a gentle curve */
@@ -4991,6 +5494,10 @@ AmLyrics$1.styles = i$3 `
4991
5494
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
4992
5495
  }
4993
5496
 
5497
+ .lyrics-word.char-drag .lyrics-syllable.cleanup .char {
5498
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
5499
+ }
5500
+
4994
5501
  .lyrics-line.persist-highlight
4995
5502
  .lyrics-word.growable
4996
5503
  .lyrics-syllable.finished
@@ -4998,6 +5505,10 @@ AmLyrics$1.styles = i$3 `
4998
5505
  .lyrics-line.persist-highlight
4999
5506
  .lyrics-word.char-rise
5000
5507
  .lyrics-syllable.finished
5508
+ .char,
5509
+ .lyrics-line.persist-highlight
5510
+ .lyrics-word.char-drag
5511
+ .lyrics-syllable.finished
5001
5512
  .char {
5002
5513
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
5003
5514
  }
@@ -5108,70 +5619,56 @@ AmLyrics$1.styles = i$3 `
5108
5619
  transform 0.7s ease;
5109
5620
  }
5110
5621
 
5622
+ .lyrics-word.char-drag span.char {
5623
+ transition: color 0.18s;
5624
+ }
5625
+
5111
5626
  /* Active char spans: structural only, wipe animation sets gradient */
5112
5627
  .lyrics-line.active .lyrics-syllable span.char {
5113
5628
  background-clip: text;
5114
5629
  -webkit-background-clip: text;
5115
5630
  background-repeat: no-repeat;
5116
- background-image:
5117
- linear-gradient(
5118
- 90deg,
5119
- #ffffff00 0%,
5120
- var(--lyplus-text-primary, #fff) 50%,
5121
- #0000 100%
5122
- ),
5123
- linear-gradient(
5124
- 90deg,
5125
- var(--lyplus-text-primary, #fff) 100%,
5126
- #0000 100%
5127
- );
5128
- background-size:
5129
- 0.5em 100%,
5130
- 0% 100%;
5131
- background-position:
5132
- -0.5em 0%,
5133
- -0.25em 0%;
5631
+ background-image: linear-gradient(
5632
+ 90deg,
5633
+ var(--lyplus-text-primary, #fff) 0%,
5634
+ var(--lyplus-text-primary, #fff)
5635
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5636
+ #0000 100%
5637
+ );
5638
+ background-size: 0% 100%;
5639
+ background-position: left;
5134
5640
  transition:
5135
5641
  transform 0.7s ease,
5136
5642
  color 0.18s;
5137
5643
  }
5138
5644
 
5139
5645
  .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%;
5154
- }
5155
-
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%;
5646
+ background-image: linear-gradient(
5647
+ -90deg,
5648
+ var(--lyplus-text-primary, #fff) 0%,
5649
+ var(--lyplus-text-primary, #fff)
5650
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5651
+ #0000 100%
5652
+ );
5653
+ background-size: 0% 100%;
5654
+ background-position: right 0%;
5655
+ }
5656
+
5657
+ .lyrics-line.active .lyrics-syllable span.char.pre-wipe-lead {
5658
+ animation-name: pre-wipe-word-char;
5659
+ animation-duration: var(--pre-wipe-duration);
5660
+ animation-delay: var(--pre-wipe-delay);
5661
+ animation-timing-function: linear;
5662
+ animation-fill-mode: forwards;
5663
+ background-image: linear-gradient(
5664
+ 90deg,
5665
+ var(--lyplus-text-primary, #fff) 0%,
5666
+ var(--lyplus-text-primary, #fff)
5667
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
5668
+ #0000 100%
5669
+ );
5670
+ background-size: 0% 100%;
5671
+ background-position: var(--char-wipe-position, left) 0%;
5175
5672
  }
5176
5673
 
5177
5674
  /* ==========================================================================
@@ -5248,13 +5745,7 @@ AmLyrics$1.styles = i$3 `
5248
5745
  color: var(--lyplus-text-primary) !important;
5249
5746
  }
5250
5747
 
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 {
5748
+ .lyrics-line.active .lyrics-syllable.line-synced span.char {
5258
5749
  background-image: none !important;
5259
5750
  background-color: var(--lyplus-text-primary) !important;
5260
5751
  transition: background-color 120ms ease-out !important;
@@ -5427,6 +5918,49 @@ AmLyrics$1.styles = i$3 `
5427
5918
  gap: 4px;
5428
5919
  }
5429
5920
 
5921
+ .source-switch-btn {
5922
+ position: relative;
5923
+ display: inline-flex;
5924
+ align-items: center;
5925
+ padding: 2px 8px;
5926
+ border: 1px solid rgba(255, 255, 255, 0.2);
5927
+ min-height: 28px;
5928
+ background: transparent;
5929
+ border-radius: 6px;
5930
+ color: #aaa;
5931
+ cursor: pointer;
5932
+ font-family: inherit;
5933
+ font-size: 11px;
5934
+ transition:
5935
+ color 0.2s ease,
5936
+ border-color 0.2s ease,
5937
+ background-color 0.2s ease,
5938
+ transform 0.12s ease;
5939
+ }
5940
+
5941
+ .source-switch-btn::before {
5942
+ content: '';
5943
+ position: absolute;
5944
+ inset: -6px;
5945
+ }
5946
+
5947
+ .source-switch-btn:active:not(:disabled) {
5948
+ transform: scale(0.96);
5949
+ }
5950
+
5951
+ .source-switch-btn:disabled {
5952
+ cursor: default;
5953
+ opacity: 0.7;
5954
+ }
5955
+
5956
+ .source-switch-svg {
5957
+ margin-right: 4px;
5958
+ }
5959
+
5960
+ .source-switch-svg.is-loading {
5961
+ animation: source-switch-spin 1s linear infinite;
5962
+ }
5963
+
5430
5964
  .control-button {
5431
5965
  background: transparent;
5432
5966
  border: 1px solid rgba(255, 255, 255, 0.3);
@@ -5435,7 +5969,10 @@ AmLyrics$1.styles = i$3 `
5435
5969
  font-size: 0.8em;
5436
5970
  color: rgba(255, 255, 255, 0.6);
5437
5971
  cursor: pointer;
5438
- transition: all 0.2s;
5972
+ transition:
5973
+ color 0.2s,
5974
+ border-color 0.2s,
5975
+ background-color 0.2s;
5439
5976
  font-weight: normal;
5440
5977
  }
5441
5978
 
@@ -5573,137 +6110,125 @@ AmLyrics$1.styles = i$3 `
5573
6110
  KEYFRAME ANIMATIONS
5574
6111
  ========================================================================== */
5575
6112
 
6113
+ @keyframes source-switch-spin {
6114
+ to {
6115
+ transform: rotate(360deg);
6116
+ }
6117
+ }
6118
+
5576
6119
  /* Wipe animation for syllables */
5577
6120
  @keyframes wipe {
5578
6121
  from {
5579
- background-size:
5580
- 0.75em 100%,
5581
- 0% 100%;
5582
- background-position:
5583
- -0.375em 0%,
5584
- left;
6122
+ background-size: 0% 100%;
6123
+ background-position: left;
6124
+ }
6125
+ to {
6126
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6127
+ background-position: left;
6128
+ }
6129
+ }
6130
+
6131
+ @keyframes wipe-from-pre {
6132
+ from {
6133
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6134
+ background-position: left;
5585
6135
  }
5586
6136
  to {
5587
- background-size:
5588
- 0.75em 100%,
5589
- 100% 100%;
5590
- background-position:
5591
- calc(100% + 0.375em) 0%,
5592
- left;
6137
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6138
+ background-position: left;
5593
6139
  }
5594
6140
  }
5595
6141
 
5596
6142
  @keyframes start-wipe {
5597
6143
  0% {
5598
- background-size:
5599
- 0.75em 100%,
5600
- 0% 100%;
5601
- background-position:
5602
- -0.75em 0%,
5603
- -0.375em 0%;
6144
+ background-size: 0% 100%;
6145
+ background-position: left;
5604
6146
  }
5605
6147
  100% {
5606
- background-size:
5607
- 0.75em 100%,
5608
- 100% 100%;
5609
- background-position:
5610
- calc(100% + 0.375em) 0%,
5611
- left;
6148
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6149
+ background-position: left;
5612
6150
  }
5613
6151
  }
5614
6152
 
5615
6153
  @keyframes wipe-rtl {
5616
6154
  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%;
6155
+ background-size: 0% 100%;
6156
+ background-position: right 0%;
5623
6157
  }
5624
6158
  to {
5625
- background-size:
5626
- 0.75em 100%,
5627
- 100% 100%;
5628
- background-position:
5629
- -0.75em 0%,
5630
- right 0%;
6159
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6160
+ background-position: right 0%;
6161
+ }
6162
+ }
6163
+
6164
+ @keyframes wipe-from-pre-rtl {
6165
+ from {
6166
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6167
+ background-position: right 0%;
6168
+ }
6169
+ to {
6170
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6171
+ background-position: right 0%;
5631
6172
  }
5632
6173
  }
5633
6174
 
5634
6175
  @keyframes start-wipe-rtl {
5635
6176
  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%;
6177
+ background-size: 0% 100%;
6178
+ background-position: right 0%;
5642
6179
  }
5643
6180
  100% {
5644
- background-size:
5645
- 0.75em 100%,
5646
- 100% 100%;
5647
- background-position:
5648
- -0.75em 0%,
5649
- right 0%;
6181
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6182
+ background-position: right 0%;
5650
6183
  }
5651
6184
  }
5652
6185
 
5653
6186
  @keyframes pre-wipe-universal {
5654
6187
  from {
5655
- background-size:
5656
- 0.75em 100%,
5657
- 0% 100%;
5658
- background-position:
5659
- -0.75em 0%,
5660
- left;
6188
+ background-size: 0% 100%;
6189
+ background-position: left;
5661
6190
  }
5662
6191
  to {
5663
- background-size:
5664
- 0.75em 100%,
5665
- 0% 100%;
5666
- background-position:
5667
- -0.375em 0%,
5668
- left;
6192
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6193
+ background-position: left;
5669
6194
  }
5670
6195
  }
5671
6196
 
5672
6197
  @keyframes pre-wipe-universal-rtl {
5673
6198
  from {
5674
- background-size:
5675
- 0.75em 100%,
5676
- 0% 100%;
5677
- background-position:
5678
- calc(100% + 0.75em) 0%,
5679
- right 0%;
6199
+ background-size: 0% 100%;
6200
+ background-position: right 0%;
6201
+ }
6202
+ to {
6203
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6204
+ background-position: right 0%;
6205
+ }
6206
+ }
6207
+
6208
+ @keyframes pre-wipe-word-char {
6209
+ from {
6210
+ background-size: 0px 100%;
6211
+ background-position: var(--char-wipe-position, left) 0%;
5680
6212
  }
5681
6213
  to {
5682
- background-size:
5683
- 0.75em 100%,
5684
- 0% 100%;
5685
- background-position:
5686
- calc(100% + 0.375em) 0%,
5687
- right 0%;
6214
+ background-size: var(--pre-wipe-word-size, var(--wipe-gradient-width))
6215
+ 100%;
6216
+ background-position: var(--char-wipe-position, left) 0%;
5688
6217
  }
5689
6218
  }
5690
6219
 
5691
- @keyframes pre-wipe-char {
6220
+ @keyframes wipe-word-from-pre {
5692
6221
  from {
5693
- background-size:
5694
- 0.75em 100%,
5695
- 0% 100%;
5696
- background-position:
5697
- -0.75em 0%,
5698
- left;
6222
+ background-size: var(--pre-wipe-word-size, var(--wipe-gradient-width))
6223
+ 100%;
6224
+ background-position: var(--char-wipe-position, left) 0%;
5699
6225
  }
5700
6226
  to {
5701
- background-size:
5702
- 0.75em 100%,
5703
- 0% 100%;
5704
- background-position:
5705
- -0.375em 0%,
5706
- left;
6227
+ background-size: calc(
6228
+ var(--word-wipe-width, 100%) + var(--wipe-gradient-width, 0.75em)
6229
+ )
6230
+ 100%;
6231
+ background-position: var(--char-wipe-position, left) 0%;
5707
6232
  }
5708
6233
  }
5709
6234
 
@@ -5797,6 +6322,15 @@ AmLyrics$1.styles = i$3 `
5797
6322
  }
5798
6323
  }
5799
6324
 
6325
+ @keyframes drag-char {
6326
+ 0% {
6327
+ transform: translate3d(0, 0, 0);
6328
+ }
6329
+ 100% {
6330
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
6331
+ }
6332
+ }
6333
+
5800
6334
  @keyframes grow-static {
5801
6335
  0%,
5802
6336
  100% {