@uimaxbai/am-lyrics 1.5.3 → 1.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/react.js CHANGED
@@ -322,7 +322,7 @@ class GoogleService {
322
322
  }
323
323
  }
324
324
 
325
- const VERSION = '1.5.3';
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);
@@ -2422,25 +2487,46 @@ let AmLyrics$1 = class AmLyrics extends i {
2422
2487
  lineIsRTL = true;
2423
2488
  const hasHyphen = combinedText.includes('-');
2424
2489
  const wordLen = combinedText.length;
2425
- let isGrowableVW = !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 7;
2490
+ const canAnimateByChar = !isCJK && !isRTL && !hasHyphen && wordLen > 0;
2491
+ const isLineSynced = line.isWordSynced === false || line.text.some(s => s.lineSynced);
2492
+ let isGrowableVW = canAnimateByChar && wordLen > 0 && wordLen <= 7;
2426
2493
  if (isGrowableVW) {
2427
- if (wordLen < 3) {
2494
+ if (wordLen <= 1) {
2428
2495
  isGrowableVW =
2429
2496
  combinedDuration >= 1050 && combinedDuration >= wordLen * 525;
2430
2497
  }
2498
+ else if (wordLen <= 3) {
2499
+ isGrowableVW =
2500
+ combinedDuration >=
2501
+ SHORT_WORD_GLOW_MIN_DURATION_MS + (wordLen - 2) * 140;
2502
+ }
2431
2503
  else {
2432
2504
  isGrowableVW =
2433
2505
  combinedDuration >= 850 && combinedDuration >= wordLen * 190;
2434
2506
  }
2435
2507
  }
2436
- const isLineSynced = line.isWordSynced === false || line.text.some(s => s.lineSynced);
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);
2513
+ const hasLongShortWordDuration = wordLen >= 4 && combinedDuration >= Math.max(1300, wordLen * 260);
2514
+ const isCharRiseVW = canAnimateByChar &&
2515
+ !isLineSynced &&
2516
+ !isGrowableVW &&
2517
+ ((wordLen >= 8 && hasCharRiseDuration) ||
2518
+ (wordLen < 8 && hasLongShortWordDuration));
2519
+ const isCharDragVW = canAnimateByChar &&
2520
+ !isLineSynced &&
2521
+ !isGrowableVW &&
2522
+ hasTinyWordDragDuration;
2437
2523
  const isGlowingVW = isGrowableVW && !isLineSynced;
2438
- const isCharRiseVW = !isGrowableVW && !isLineSynced && !isCJK && !isRTL && wordLen >= 8;
2439
2524
  let charOff = 0;
2440
2525
  for (let gi = vwStart; gi <= vwEnd; gi += 1) {
2441
2526
  groupGrowable[gi] = isGrowableVW;
2442
2527
  groupGlowing[gi] = isGlowingVW;
2443
2528
  groupCharRise[gi] = isCharRiseVW;
2529
+ groupCharDrag[gi] = isCharDragVW;
2444
2530
  vwFullText[gi] = combinedText;
2445
2531
  vwFullDuration[gi] = combinedDuration;
2446
2532
  vwCharOffset[gi] = charOff;
@@ -2456,6 +2542,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2456
2542
  groupGrowable,
2457
2543
  groupGlowing,
2458
2544
  groupCharRise,
2545
+ groupCharDrag,
2459
2546
  vwFullText,
2460
2547
  vwFullDuration,
2461
2548
  vwCharOffset,
@@ -2475,48 +2562,97 @@ let AmLyrics$1 = class AmLyrics extends i {
2475
2562
  return;
2476
2563
  const computedStyle = getComputedStyle(referenceSyllable);
2477
2564
  const { font } = computedStyle; // Full font string
2478
- const fontSize = parseFloat(computedStyle.fontSize);
2479
- const charTimedWords = this.shadowRoot.querySelectorAll('.lyrics-word.growable, .lyrics-word.char-rise');
2480
- 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)
2481
2567
  return;
2482
- charTimedWords.forEach((wordSpan) => {
2483
- const syllableWraps = wordSpan.querySelectorAll('.lyrics-syllable-wrap');
2484
- // 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 => {
2485
2580
  const syllables = [];
2486
- syllableWraps.forEach((wrap) => {
2487
- const syl = wrap.querySelector('.lyrics-syllable');
2488
- if (syl)
2489
- 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
+ });
2490
2588
  });
2491
- syllables.forEach(sylSpan => {
2492
- const charSpans = sylSpan.querySelectorAll('.char');
2493
- if (charSpans.length === 0)
2494
- return;
2495
- // Logic from YouLyPlus renderCharWipes:
2496
- // Use textContent from spans to ensure we measure what is rendered
2497
- const chars = Array.from(charSpans).map(span => span.textContent || '');
2498
- const charWidths = chars.map(c => this._getTextWidth(c, font));
2499
- const totalSyllableWidth = charWidths.reduce((a, b) => a + b, 0);
2500
- const duration = parseFloat(sylSpan.dataset.duration || '0');
2501
- const velocityPxPerMs = duration > 0 ? totalSyllableWidth / duration : 0;
2502
- // Gradient width in pixels = 0.375 * fontSize
2503
- // This matches YouLyPlus visual gradient size
2504
- const gradientWidthPx = 0.375 * fontSize;
2505
- const gradientDurationMs = velocityPxPerMs > 0 ? gradientWidthPx / velocityPxPerMs : 100;
2506
- let cumulativeCharWidth = 0;
2507
- charSpans.forEach((spanArg, i) => {
2508
- const charWidth = charWidths[i];
2509
- const span = spanArg;
2510
- if (totalSyllableWidth > 0) {
2511
- const startPercent = cumulativeCharWidth / totalSyllableWidth;
2512
- const durationPercent = charWidth / totalSyllableWidth;
2513
- span.dataset.wipeStart = startPercent.toFixed(4);
2514
- span.dataset.wipeDuration = durationPercent.toFixed(4);
2515
- // The critical missing piece:
2516
- span.dataset.preWipeArrival = (duration * startPercent).toFixed(2);
2517
- 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);
2518
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`);
2519
2654
  cumulativeCharWidth += charWidth;
2655
+ cumulativeSyllableWidth += charWidth;
2520
2656
  });
2521
2657
  });
2522
2658
  });
@@ -2524,6 +2660,33 @@ let AmLyrics$1 = class AmLyrics extends i {
2524
2660
  static arraysEqual(a, b) {
2525
2661
  return a.length === b.length && a.every((val, i) => val === b[i]);
2526
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
+ }
2527
2690
  static getLineIndexFromElement(lineElement) {
2528
2691
  if (!lineElement)
2529
2692
  return null;
@@ -2554,6 +2717,26 @@ let AmLyrics$1 = class AmLyrics extends i {
2554
2717
  }
2555
2718
  this.preActiveLineElements = keptLines;
2556
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
+ }
2557
2740
  getPrimaryActiveLineIndex(activeIndices) {
2558
2741
  if (activeIndices.length === 0)
2559
2742
  return null;
@@ -2743,9 +2926,10 @@ let AmLyrics$1 = class AmLyrics extends i {
2743
2926
  const activeLines = [];
2744
2927
  for (let i = 0; i < this.lyrics.length; i += 1) {
2745
2928
  const line = this.lyrics[i];
2929
+ const highlightEndTime = this.getLineHighlightEndTime(i);
2746
2930
  if (line.timestamp > time)
2747
2931
  break;
2748
- if (time >= line.timestamp && time < line.endtime) {
2932
+ if (time >= line.timestamp && time < highlightEndTime) {
2749
2933
  activeLines.push(i);
2750
2934
  }
2751
2935
  }
@@ -2970,6 +3154,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2970
3154
  this.lastPrimaryActiveLine = null;
2971
3155
  this.activeLineIds.clear();
2972
3156
  this.animatingLines = [];
3157
+ this.setBackgroundExpandedLine(null);
2973
3158
  // Find the clicked line element and scroll to it with forceScroll (like YouLyPlus)
2974
3159
  // Timestamps are already in milliseconds — match the data-start-time attribute directly
2975
3160
  const clickedLineElement = this.lyricsContainer?.querySelector(`.lyrics-line[data-start-time="${line.text[0]?.timestamp || 0}"]`);
@@ -2986,6 +3171,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2986
3171
  this.isClickSeeking = false;
2987
3172
  }, 800);
2988
3173
  this.scrollToActiveLineYouLy(clickedLineElement, true);
3174
+ this.setBackgroundExpandedLine(clickedLineElement);
2989
3175
  }
2990
3176
  const event = new CustomEvent('line-click', {
2991
3177
  detail: {
@@ -3331,21 +3517,259 @@ let AmLyrics$1 = class AmLyrics extends i {
3331
3517
  }
3332
3518
  /**
3333
3519
  * Update syllable highlight animation - apply CSS wipe animation
3334
- * (Exact copy from YouLyPlus _updateSyllableAnimation)
3335
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
+ }
3336
3753
  static updateSyllableAnimation(syllable, elapsedTimeMs = 0) {
3337
3754
  if (syllable.classList.contains('highlight'))
3338
3755
  return;
3339
3756
  const { classList } = syllable;
3757
+ const hadPreHighlight = classList.contains('pre-highlight');
3340
3758
  const isRTL = classList.contains('rtl-text');
3341
- const charSpans = Array.from(syllable.querySelectorAll('span.char'));
3759
+ const charSpans = AmLyrics.getCachedCharSpans(syllable);
3342
3760
  const wordElement = syllable.parentElement?.parentElement; // syllable-wrap -> word
3343
- const allWordCharSpans = wordElement
3344
- ? Array.from(wordElement.querySelectorAll('span.char'))
3345
- : [];
3346
- const isGrowable = wordElement?.classList.contains('growable');
3347
- 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');
3348
3767
  const isFirstSyllable = syllable.getAttribute('data-syllable-index') === '0';
3768
+ const syllableStartMs = parseFloat(syllable.getAttribute('data-start-time') || '0');
3769
+ const virtualWordStartMs = parseFloat(typedWordElement?.dataset.virtualWordStart || '');
3770
+ const isFirstInVirtualWord = isFirstSyllable &&
3771
+ (!Number.isFinite(virtualWordStartMs) ||
3772
+ Math.abs(syllableStartMs - virtualWordStartMs) < 0.5);
3349
3773
  const isFirstInContainer = isFirstSyllable; // Simplified
3350
3774
  const isGap = syllable.closest('.lyrics-gap') !== null;
3351
3775
  // Get duration from data attribute
@@ -3353,11 +3777,14 @@ let AmLyrics$1 = class AmLyrics extends i {
3353
3777
  const wordDurationMs = parseFloat(syllable.getAttribute('data-word-duration') ||
3354
3778
  syllable.getAttribute('data-duration') ||
3355
3779
  '0') || syllableDurationMs;
3356
- // 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);
3357
3784
  const charAnimationsMap = new Map();
3358
3785
  const styleUpdates = [];
3359
3786
  // Step 1: Grow Pass
3360
- if (isGrowable && isFirstSyllable && allWordCharSpans.length > 0) {
3787
+ if (isGrowable && isFirstInVirtualWord && allWordCharSpans.length > 0) {
3361
3788
  const finalDuration = wordDurationMs;
3362
3789
  const baseDelayPerChar = finalDuration * 0.09;
3363
3790
  const growDurationMs = finalDuration * 1.5;
@@ -3391,7 +3818,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3391
3818
  });
3392
3819
  });
3393
3820
  }
3394
- if (isCharRise && isFirstSyllable && allWordCharSpans.length > 0) {
3821
+ if (isCharRise && isFirstInVirtualWord && allWordCharSpans.length > 0) {
3395
3822
  const finalDuration = Math.max(wordDurationMs, syllableDurationMs);
3396
3823
  const baseDelayPerChar = finalDuration * 0.09;
3397
3824
  const riseDurationMs = finalDuration * 1.5;
@@ -3401,16 +3828,60 @@ let AmLyrics$1 = class AmLyrics extends i {
3401
3828
  charAnimationsMap.set(span, `rise-char ${riseDurationMs}ms ease-in-out ${riseDelay}ms forwards`);
3402
3829
  });
3403
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
+ }
3404
3841
  // Step 2: Wipe Pass
3405
3842
  if (charSpans.length > 0) {
3406
- 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) => {
3407
3868
  const startPct = parseFloat(span.dataset.wipeStart || '0');
3408
3869
  const durationPct = parseFloat(span.dataset.wipeDuration || '0');
3409
- const wipeDelay = syllableDurationMs * startPct - elapsedTimeMs;
3410
- const wipeDuration = syllableDurationMs * durationPct;
3411
- 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;
3412
3878
  let charWipeAnimation = 'wipe';
3413
- if (useStartAnimation) {
3879
+ if (hadCharPreWipe) {
3880
+ charWipeAnimation = 'wipe-word-from-pre';
3881
+ wipeDelay = -wordElapsedTimeMs;
3882
+ wipeDuration = charWipeDurationMs;
3883
+ }
3884
+ else if (useStartAnimation) {
3414
3885
  charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
3415
3886
  }
3416
3887
  else {
@@ -3420,21 +3891,13 @@ let AmLyrics$1 = class AmLyrics extends i {
3420
3891
  const animationParts = [];
3421
3892
  if (existingAnimation &&
3422
3893
  (existingAnimation.includes('grow-dynamic') ||
3423
- existingAnimation.includes('rise-char'))) {
3894
+ existingAnimation.includes('rise-char') ||
3895
+ existingAnimation.includes('drag-char'))) {
3424
3896
  animationParts.push(existingAnimation.split(',')[0].trim());
3425
3897
  }
3426
- if (charIndex > 0) {
3427
- const arrivalTime = (span.dataset.preWipeArrival
3428
- ? parseFloat(span.dataset.preWipeArrival)
3429
- : syllableDurationMs * startPct) - elapsedTimeMs;
3430
- const constantDuration = parseFloat(span.dataset.preWipeDuration || '100');
3431
- const animDelay = arrivalTime - constantDuration;
3432
- if (constantDuration > 0) {
3433
- animationParts.push(`pre-wipe-char ${constantDuration}ms linear ${animDelay}ms forwards`);
3434
- }
3435
- }
3436
3898
  if (wipeDuration > 0) {
3437
- 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}`);
3438
3901
  }
3439
3902
  if (animationParts.length > 0) {
3440
3903
  charAnimationsMap.set(span, animationParts.join(', '));
@@ -3444,9 +3907,15 @@ let AmLyrics$1 = class AmLyrics extends i {
3444
3907
  else {
3445
3908
  // Syllable-level wipe for regular (non-growable) words without chars
3446
3909
  const wipeRatio = parseFloat(syllable.getAttribute('data-wipe-ratio') || '1');
3447
- 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);
3448
3914
  let wipeAnimation = 'wipe';
3449
- if (isFirstInContainer) {
3915
+ if (hadPreHighlight) {
3916
+ wipeAnimation = isRTL ? 'wipe-from-pre-rtl' : 'wipe-from-pre';
3917
+ }
3918
+ else if (isFirstInContainer) {
3450
3919
  wipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
3451
3920
  }
3452
3921
  else {
@@ -3459,16 +3928,25 @@ let AmLyrics$1 = class AmLyrics extends i {
3459
3928
  syllable.style.animation = `${currentWipeAnimation} ${visualDuration}ms ${isGap ? 'ease-out' : 'linear'} ${-elapsedTimeMs}ms forwards`;
3460
3929
  }
3461
3930
  // --- WRITE PHASE ---
3931
+ if (allWordElements.length > 0) {
3932
+ allWordElements.forEach(element => {
3933
+ const target = element;
3934
+ target._wordPreWipeKey = undefined;
3935
+ });
3936
+ }
3462
3937
  classList.remove('pre-highlight');
3463
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
+ }
3464
3945
  for (const [span, animationString] of charAnimationsMap.entries()) {
3465
3946
  span.style.willChange = 'transform';
3947
+ span.style.removeProperty('background-color');
3466
3948
  span.style.animation = animationString;
3467
3949
  }
3468
- // Apply style updates
3469
- for (const update of styleUpdates) {
3470
- update.element.style.setProperty(update.property, update.value);
3471
- }
3472
3950
  }
3473
3951
  /**
3474
3952
  * Reset syllable animation state
@@ -3492,10 +3970,19 @@ let AmLyrics$1 = class AmLyrics extends i {
3492
3970
  el.style.animation = '';
3493
3971
  el.style.transition = 'none';
3494
3972
  el.style.backgroundColor = 'var(--lyplus-text-secondary)';
3973
+ AmLyrics.clearPreWipeLead(el);
3495
3974
  }
3496
3975
  // Immediately remove all state classes
3497
3976
  syllable.classList.remove('highlight', 'finished', 'pre-highlight', 'cleanup');
3498
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
+ }
3499
3986
  /**
3500
3987
  * Reset all syllables in a line — batches deferred cleanup into a single rAF
3501
3988
  */
@@ -3503,6 +3990,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3503
3990
  if (!line)
3504
3991
  return;
3505
3992
  line.classList.remove('persist-highlight');
3993
+ AmLyrics.resetWordAnimationState(line);
3506
3994
  // eslint-disable-next-line no-param-reassign
3507
3995
  line._cachedSyllableElements = null;
3508
3996
  const syllables = line.getElementsByClassName('lyrics-syllable');
@@ -3534,6 +4022,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3534
4022
  if (!line)
3535
4023
  return;
3536
4024
  line.classList.remove('persist-highlight');
4025
+ AmLyrics.resetWordAnimationState(line);
3537
4026
  const syllables = line.getElementsByClassName('lyrics-syllable');
3538
4027
  for (let i = 0; i < syllables.length; i += 1) {
3539
4028
  const s = syllables[i];
@@ -3551,6 +4040,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3551
4040
  el.style.removeProperty('background-color');
3552
4041
  el.style.removeProperty('transition');
3553
4042
  el.style.removeProperty('filter');
4043
+ AmLyrics.clearPreWipeLead(el);
3554
4044
  }
3555
4045
  }
3556
4046
  }
@@ -3587,19 +4077,26 @@ let AmLyrics$1 = class AmLyrics extends i {
3587
4077
  syllable.style.animation = '';
3588
4078
  syllable.style.removeProperty('--pre-wipe-duration');
3589
4079
  syllable.style.removeProperty('--pre-wipe-delay');
4080
+ syllable.style.removeProperty('background-color');
4081
+ AmLyrics.applyWipeShape(syllable, AmLyrics.getVisibleCharacterCount(syllable));
3590
4082
  const chars = syllable.querySelectorAll('span.char');
3591
4083
  for (let ci = 0; ci < chars.length; ci += 1) {
3592
4084
  const charEl = chars[ci];
3593
4085
  const currentAnim = charEl.style.animation || '';
3594
4086
  if (currentAnim.includes('grow-dynamic') ||
3595
- currentAnim.includes('rise-char')) {
4087
+ currentAnim.includes('rise-char') ||
4088
+ currentAnim.includes('drag-char')) {
3596
4089
  const parts = currentAnim.split(',').map(p => p.trim());
3597
- 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'));
3598
4093
  charEl.style.animation = transformAnim || '';
3599
4094
  }
3600
4095
  else {
3601
4096
  charEl.style.animation = '';
3602
4097
  }
4098
+ charEl.style.backgroundColor = 'var(--lyplus-text-primary)';
4099
+ AmLyrics.clearPreWipeLead(charEl);
3603
4100
  }
3604
4101
  }
3605
4102
  }
@@ -3640,14 +4137,16 @@ let AmLyrics$1 = class AmLyrics extends i {
3640
4137
  // Early exit check
3641
4138
  if (!(currentTimeMs < startTime - 1000 && !hasActiveState)) {
3642
4139
  let preHighlightReset = false;
3643
- // Pre-highlight reset logic
3644
- if (hasPreHighlight && i > 0) {
3645
- const prevSyllable = syllables[i - 1];
3646
- if (!prevSyllable.classList.contains('highlight')) {
3647
- classList.remove('pre-highlight');
3648
- syllable.style.removeProperty('--pre-wipe-duration');
3649
- syllable.style.removeProperty('--pre-wipe-delay');
3650
- 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);
3651
4150
  preHighlightReset = true;
3652
4151
  }
3653
4152
  }
@@ -3675,6 +4174,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3675
4174
  // Not yet started
3676
4175
  AmLyrics.resetSyllable(syllable);
3677
4176
  }
4177
+ AmLyrics.maybePreWipeNextWord(syllables, i, currentTimeMs, endTime);
3678
4178
  }
3679
4179
  }
3680
4180
  }
@@ -3984,6 +4484,9 @@ let AmLyrics$1 = class AmLyrics extends i {
3984
4484
  data-end-time="${endTimeMs}"
3985
4485
  data-duration="${durationMs}"
3986
4486
  data-syllable-index="${syllableIndex}"
4487
+ data-word-index="${syllableIndex}"
4488
+ data-word-length="${syllable.text.replace(/\s/g, '')
4489
+ .length}"
3987
4490
  data-wipe-ratio="1"
3988
4491
  >${syllable.text}</span
3989
4492
  >${bgRomanizedText}</span
@@ -4005,11 +4508,13 @@ let AmLyrics$1 = class AmLyrics extends i {
4005
4508
  const groupGrowable = lineData?.groupGrowable ?? [];
4006
4509
  const groupGlowing = lineData?.groupGlowing ?? [];
4007
4510
  const groupCharRise = lineData?.groupCharRise ?? [];
4511
+ const groupCharDrag = lineData?.groupCharDrag ?? [];
4008
4512
  const vwFullText = lineData?.vwFullText ?? [];
4009
4513
  const vwFullDuration = lineData?.vwFullDuration ?? [];
4010
4514
  const vwCharOffset = lineData?.vwCharOffset ?? [];
4515
+ const vwStartMs = lineData?.vwStartMs ?? [];
4516
+ const vwEndMs = lineData?.vwEndMs ?? [];
4011
4517
  const lineIsRTL = lineData?.lineIsRTL ?? false;
4012
- // Create main vocals using YouLyPlus syllable structure
4013
4518
  const mainVocalElement = b `<p
4014
4519
  class="main-vocal-container ${lineIsRTL ? 'rtl-text' : ''}"
4015
4520
  >
@@ -4017,18 +4522,23 @@ let AmLyrics$1 = class AmLyrics extends i {
4017
4522
  const isGrowable = groupGrowable[groupIdx];
4018
4523
  const isGlowing = groupGlowing[groupIdx];
4019
4524
  const isCharRise = groupCharRise[groupIdx];
4020
- const isAnimatedByChar = isGrowable || isCharRise;
4525
+ const isCharDrag = groupCharDrag[groupIdx];
4526
+ const isAnimatedByChar = isGrowable || isCharRise || isCharDrag;
4021
4527
  const groupLineSynced = group.some(s => s.lineSynced);
4022
4528
  const wordText = isAnimatedByChar ? vwFullText[groupIdx] : '';
4023
4529
  const wordDuration = isAnimatedByChar
4024
4530
  ? vwFullDuration[groupIdx]
4025
4531
  : 0;
4026
- const wordNumChars = wordText.length;
4532
+ const wordNumChars = wordText.replace(/\s/g, '').length;
4027
4533
  const groupCharOffset = isAnimatedByChar
4028
4534
  ? vwCharOffset[groupIdx]
4029
4535
  : 0;
4536
+ const virtualWordId = `${lineIndex}:${vwStartMs[groupIdx]}:${vwEndMs[groupIdx]}`;
4537
+ const virtualWordStart = vwStartMs[groupIdx];
4538
+ const virtualWordEnd = vwEndMs[groupIdx];
4030
4539
  let sylCharAccumulator = 0;
4031
4540
  const groupText = group.map(s => s.text).join('');
4541
+ const visibleWordLength = groupText.replace(/\s/g, '').length;
4032
4542
  const shouldAllowBreak = groupText.trim().length >= 16 ||
4033
4543
  /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(groupText);
4034
4544
  // Calculate dynamic rise duration based on the audio duration of the word
@@ -4040,9 +4550,12 @@ let AmLyrics$1 = class AmLyrics extends i {
4040
4550
  return b `<span
4041
4551
  class="lyrics-word${isGrowable ? ' growable' : ''}${isCharRise
4042
4552
  ? ' char-rise'
4043
- : ''}${isGlowing ? ' glowing' : ''}${shouldAllowBreak
4044
- ? ' allow-break'
4045
- : ''}"
4553
+ : ''}${isCharDrag ? ' char-drag' : ''}${isGlowing
4554
+ ? ' glowing'
4555
+ : ''}${shouldAllowBreak ? ' allow-break' : ''}"
4556
+ data-virtual-word-id="${virtualWordId}"
4557
+ data-virtual-word-start="${virtualWordStart}"
4558
+ data-virtual-word-end="${virtualWordEnd}"
4046
4559
  style="--rise-duration: ${riseDuration}s"
4047
4560
  >${group.map((syllable, sylIdx) => {
4048
4561
  const startTimeMs = syllable.timestamp;
@@ -4066,13 +4579,26 @@ let AmLyrics$1 = class AmLyrics extends i {
4066
4579
  : '';
4067
4580
  let syllableContent = text;
4068
4581
  if (isAnimatedByChar) {
4069
- let charIndexInsideSyllable = 0;
4070
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;
4071
4591
  syllableContent = b `${text.split('').map(char => {
4072
4592
  if (char === ' ')
4073
4593
  return ' ';
4074
4594
  const charIndexInsideWord = groupCharOffset + sylCharAccumulator;
4075
- 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;
4076
4602
  sylCharAccumulator += 1;
4077
4603
  charIndexInsideSyllable += 1;
4078
4604
  const minDuration = 400;
@@ -4119,21 +4645,37 @@ let AmLyrics$1 = class AmLyrics extends i {
4119
4645
  const normalizedGrowth = (charMaxScale - 1.0) / 0.1;
4120
4646
  const effectiveDuration = (wordDuration + durationMs * 2) / 3;
4121
4647
  const peakMultiplier = Math.min(1, Math.max(0.3, effectiveDuration / 2000));
4122
- const charTranslateYPeak = -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
4648
+ const baseTranslateYPeak = -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
4123
4649
  const position = (charIndexInsideWord + 0.5) / wordNumChars;
4124
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
+ }
4125
4666
  return b `<span
4126
4667
  class="char"
4127
4668
  data-char-index="${charIndexInsideWord}"
4128
4669
  data-syllable-char-index="${charIndexInsideWord}"
4129
4670
  data-wipe-start="${charStartPercentVal.toFixed(4)}"
4130
- data-wipe-duration="${(1 / numCharsInSyllable).toFixed(4)}"
4671
+ data-wipe-duration="${charDurationPercentVal.toFixed(4)}"
4131
4672
  data-horizontal-offset="${horizontalOffset.toFixed(2)}"
4132
4673
  data-max-scale="${charMaxScale.toFixed(3)}"
4133
4674
  data-matrix-scale="${(charMaxScale * 0.98).toFixed(3)}"
4134
- data-char-offset-x="${(horizontalOffset * 0.98).toFixed(2)}"
4675
+ data-char-offset-x="${(motionHorizontalOffset * 0.98).toFixed(2)}"
4135
4676
  data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
4136
4677
  data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
4678
+ style="--word-wipe-width: ${visibleWordChars}ch; --char-wipe-position: -${charIndexInsideWord}ch"
4137
4679
  >${char}</span
4138
4680
  >`;
4139
4681
  })}`;
@@ -4151,6 +4693,8 @@ let AmLyrics$1 = class AmLyrics extends i {
4151
4693
  data-duration="${durationMs}"
4152
4694
  data-word-duration="${wordDuration}"
4153
4695
  data-syllable-index="${sylIdx}"
4696
+ data-word-index="${groupIdx}"
4697
+ data-word-length="${visibleWordLength}"
4154
4698
  data-wipe-ratio="1"
4155
4699
  >${syllableContent}</span
4156
4700
  >${romanizedText}</span
@@ -4383,13 +4927,14 @@ let AmLyrics$1 = class AmLyrics extends i {
4383
4927
  <button
4384
4928
  class="download-button source-switch-btn"
4385
4929
  title="Switch Lyrics Source"
4386
- 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;"
4387
4930
  @click=${this.switchSource}
4388
4931
  ?disabled=${this.isFetchingAlternatives}
4389
4932
  >
4390
4933
  <svg
4391
- class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
4392
- 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
+ : ''}"
4393
4938
  xmlns="http://www.w3.org/2000/svg"
4394
4939
  width="12"
4395
4940
  height="12"
@@ -4447,9 +4992,6 @@ let AmLyrics$1 = class AmLyrics extends i {
4447
4992
  }
4448
4993
  };
4449
4994
  AmLyrics$1.styles = i$3 `
4450
- /* ==========================================================================
4451
- YOULYPLUS-INSPIRED STYLING - Design Tokens & Variables
4452
- ========================================================================== */
4453
4995
  :host {
4454
4996
  --lyplus-lyrics-palette: var(
4455
4997
  --am-lyrics-highlight-color,
@@ -4478,6 +5020,8 @@ AmLyrics$1.styles = i$3 `
4478
5020
  --lyplus-blur-amount: 0.07em;
4479
5021
  --lyplus-blur-amount-near: 0.035em;
4480
5022
  --lyplus-fade-gap-timing-function: ease-out;
5023
+ --wipe-gradient-width: 0.75em;
5024
+ --wipe-gradient-half: 0.375em;
4481
5025
 
4482
5026
  --lyrics-scroll-padding-top: 25%;
4483
5027
 
@@ -4505,6 +5049,9 @@ AmLyrics$1.styles = i$3 `
4505
5049
  max-height: 100vh;
4506
5050
  overflow-y: auto;
4507
5051
  -webkit-overflow-scrolling: touch;
5052
+ -webkit-touch-callout: none;
5053
+ -webkit-user-select: none;
5054
+ user-select: none;
4508
5055
  box-sizing: border-box;
4509
5056
  scrollbar-width: none;
4510
5057
  overflow-anchor: none;
@@ -4601,17 +5148,15 @@ AmLyrics$1.styles = i$3 `
4601
5148
 
4602
5149
  .background-vocal-container {
4603
5150
  max-height: 0;
4604
- overflow: visible;
5151
+ overflow: hidden;
4605
5152
  opacity: 0;
4606
5153
  font-size: var(--lyplus-font-size-subtext);
4607
5154
  line-height: 1.15;
4608
5155
  color: color-mix(in srgb, var(--lyplus-text-secondary) 80%, transparent);
4609
- /* Fast exit (0.25 s) so bg vocals collapse quickly and feel snappy.
4610
- The scroll takes ~0.4 s; finishing the collapse first prevents
4611
- the container from trailing behind the motion. */
4612
5156
  transition:
4613
- max-height 250ms cubic-bezier(0.41, 0, 0.12, 0.99),
4614
- opacity 250ms cubic-bezier(0.41, 0, 0.12, 0.99);
5157
+ max-height var(--scroll-duration, 400ms)
5158
+ cubic-bezier(0.41, 0, 0.12, 0.99),
5159
+ opacity var(--scroll-duration, 400ms) cubic-bezier(0.41, 0, 0.12, 0.99);
4615
5160
  margin: 0;
4616
5161
  pointer-events: none;
4617
5162
  }
@@ -4620,7 +5165,8 @@ AmLyrics$1.styles = i$3 `
4620
5165
  display: block;
4621
5166
  padding-top: 0;
4622
5167
  padding-bottom: 0;
4623
- transition: padding-top 250ms cubic-bezier(0.41, 0, 0.12, 0.99);
5168
+ transition: padding-top var(--scroll-duration, 400ms)
5169
+ cubic-bezier(0.41, 0, 0.12, 0.99);
4624
5170
  }
4625
5171
 
4626
5172
  .lyrics-line.singer-right .background-vocal-container,
@@ -4635,16 +5181,11 @@ AmLyrics$1.styles = i$3 `
4635
5181
  .lyrics-line.bg-expanded .background-vocal-container {
4636
5182
  max-height: 4em;
4637
5183
  opacity: 1;
4638
- /* Slower entry (0.6 s) so bg vocals expand smoothly. */
4639
- transition:
4640
- max-height 0.6s ease,
4641
- opacity 0.6s ease;
4642
- will-change: max-height, opacity;
5184
+ will-change: opacity;
4643
5185
  }
4644
5186
 
4645
5187
  .lyrics-line.bg-expanded .background-vocal-wrap {
4646
5188
  padding-top: 0.26em;
4647
- transition: padding-top 0.6s ease;
4648
5189
  }
4649
5190
 
4650
5191
  /* --- Line States & Modifiers --- */
@@ -4792,11 +5333,22 @@ AmLyrics$1.styles = i$3 `
4792
5333
  white-space: nowrap;
4793
5334
  }
4794
5335
 
5336
+ .lyrics-word.char-drag {
5337
+ display: inline-block;
5338
+ vertical-align: baseline;
5339
+ white-space: nowrap;
5340
+ }
5341
+
4795
5342
  .lyrics-word.char-rise.allow-break {
4796
5343
  display: inline;
4797
5344
  white-space: normal;
4798
5345
  }
4799
5346
 
5347
+ .lyrics-word.char-drag.allow-break {
5348
+ display: inline;
5349
+ white-space: normal;
5350
+ }
5351
+
4800
5352
  .lyrics-syllable-wrap {
4801
5353
  display: inline;
4802
5354
  }
@@ -4851,44 +5403,30 @@ AmLyrics$1.styles = i$3 `
4851
5403
  .lyrics-line.active:not(.lyrics-gap)
4852
5404
  .lyrics-syllable.pre-highlight.no-chars {
4853
5405
  background-repeat: no-repeat;
4854
- background-image:
4855
- linear-gradient(
4856
- 90deg,
4857
- #ffffff00 0%,
4858
- var(--lyplus-text-primary, #fff) 50%,
4859
- #0000 100%
4860
- ),
4861
- linear-gradient(
4862
- 90deg,
4863
- var(--lyplus-text-primary, #fff) 100%,
4864
- #0000 100%
4865
- );
4866
- background-size:
4867
- 0.5em 100%,
4868
- 0% 100%;
4869
- background-position:
4870
- -0.5em 0%,
4871
- -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;
4872
5415
  }
4873
5416
 
4874
5417
  .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.highlight.rtl-text,
4875
5418
  .lyrics-line.active:not(.lyrics-gap)
4876
5419
  .lyrics-syllable.pre-highlight.rtl-text {
4877
5420
  direction: rtl;
4878
- background-image:
4879
- linear-gradient(
4880
- -90deg,
4881
- var(--lyplus-text-primary) 0%,
4882
- transparent 100%
4883
- ),
4884
- linear-gradient(
4885
- -90deg,
4886
- var(--lyplus-text-primary) 100%,
4887
- transparent 100%
4888
- );
4889
- background-position:
4890
- calc(100% + 0.5em) 0%,
4891
- 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%;
4892
5430
  }
4893
5431
 
4894
5432
  /* Background vocals: muted gray wipe instead of white.
@@ -4905,18 +5443,13 @@ AmLyrics$1.styles = i$3 `
4905
5443
  .lyrics-line.pre-active
4906
5444
  .background-vocal-container
4907
5445
  .lyrics-syllable.pre-highlight.no-chars {
4908
- background-image:
4909
- linear-gradient(
4910
- 90deg,
4911
- #ffffff00 0%,
4912
- color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 50%,
4913
- #0000 100%
4914
- ),
4915
- linear-gradient(
4916
- 90deg,
4917
- color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 100%,
4918
- #0000 100%
4919
- );
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
+ );
4920
5453
  }
4921
5454
 
4922
5455
  .lyrics-line.active
@@ -4931,17 +5464,13 @@ AmLyrics$1.styles = i$3 `
4931
5464
  .lyrics-line.pre-active
4932
5465
  .background-vocal-container
4933
5466
  .lyrics-syllable.pre-highlight.rtl-text {
4934
- background-image:
4935
- linear-gradient(
4936
- -90deg,
4937
- color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 0%,
4938
- transparent 100%
4939
- ),
4940
- linear-gradient(
4941
- -90deg,
4942
- color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 100%,
4943
- transparent 100%
4944
- );
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
+ );
4945
5474
  }
4946
5475
 
4947
5476
  /* Non-growable words float up with a gentle curve */
@@ -4965,6 +5494,10 @@ AmLyrics$1.styles = i$3 `
4965
5494
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
4966
5495
  }
4967
5496
 
5497
+ .lyrics-word.char-drag .lyrics-syllable.cleanup .char {
5498
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
5499
+ }
5500
+
4968
5501
  .lyrics-line.persist-highlight
4969
5502
  .lyrics-word.growable
4970
5503
  .lyrics-syllable.finished
@@ -4972,6 +5505,10 @@ AmLyrics$1.styles = i$3 `
4972
5505
  .lyrics-line.persist-highlight
4973
5506
  .lyrics-word.char-rise
4974
5507
  .lyrics-syllable.finished
5508
+ .char,
5509
+ .lyrics-line.persist-highlight
5510
+ .lyrics-word.char-drag
5511
+ .lyrics-syllable.finished
4975
5512
  .char {
4976
5513
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
4977
5514
  }
@@ -5082,70 +5619,56 @@ AmLyrics$1.styles = i$3 `
5082
5619
  transform 0.7s ease;
5083
5620
  }
5084
5621
 
5622
+ .lyrics-word.char-drag span.char {
5623
+ transition: color 0.18s;
5624
+ }
5625
+
5085
5626
  /* Active char spans: structural only, wipe animation sets gradient */
5086
5627
  .lyrics-line.active .lyrics-syllable span.char {
5087
5628
  background-clip: text;
5088
5629
  -webkit-background-clip: text;
5089
5630
  background-repeat: no-repeat;
5090
- background-image:
5091
- linear-gradient(
5092
- 90deg,
5093
- #ffffff00 0%,
5094
- var(--lyplus-text-primary, #fff) 50%,
5095
- #0000 100%
5096
- ),
5097
- linear-gradient(
5098
- 90deg,
5099
- var(--lyplus-text-primary, #fff) 100%,
5100
- #0000 100%
5101
- );
5102
- background-size:
5103
- 0.5em 100%,
5104
- 0% 100%;
5105
- background-position:
5106
- -0.5em 0%,
5107
- -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;
5108
5640
  transition:
5109
5641
  transform 0.7s ease,
5110
5642
  color 0.18s;
5111
5643
  }
5112
5644
 
5113
5645
  .lyrics-line.active .lyrics-syllable span.char.highlight {
5114
- background-image:
5115
- linear-gradient(
5116
- -90deg,
5117
- var(--lyplus-text-primary, #fff) 0%,
5118
- #0000 100%
5119
- ),
5120
- linear-gradient(
5121
- -90deg,
5122
- var(--lyplus-text-primary, #fff) 100%,
5123
- #0000 100%
5124
- );
5125
- background-position:
5126
- calc(100% + 0.5em) 0%,
5127
- calc(100% + 0.25em) 0%;
5128
- }
5129
-
5130
- .lyrics-line.active .lyrics-syllable.pre-highlight span.char {
5131
- background-image:
5132
- linear-gradient(
5133
- 90deg,
5134
- #ffffff00 0%,
5135
- var(--lyplus-text-primary, #fff) 50%,
5136
- #0000 100%
5137
- ),
5138
- linear-gradient(
5139
- 90deg,
5140
- var(--lyplus-text-primary, #fff) 100%,
5141
- #0000 100%
5142
- );
5143
- background-size:
5144
- 0.75em 100%,
5145
- 0% 100%;
5146
- background-position:
5147
- -0.85em 0%,
5148
- -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%;
5149
5672
  }
5150
5673
 
5151
5674
  /* ==========================================================================
@@ -5222,13 +5745,7 @@ AmLyrics$1.styles = i$3 `
5222
5745
  color: var(--lyplus-text-primary) !important;
5223
5746
  }
5224
5747
 
5225
- .lyrics-line.pre-active .lyrics-syllable.line-synced {
5226
- animation: fade-in-line 0.14s ease-out forwards !important;
5227
- color: var(--lyplus-text-primary) !important;
5228
- }
5229
-
5230
- .lyrics-line.active .lyrics-syllable.line-synced span.char,
5231
- .lyrics-line.pre-active .lyrics-syllable.line-synced span.char {
5748
+ .lyrics-line.active .lyrics-syllable.line-synced span.char {
5232
5749
  background-image: none !important;
5233
5750
  background-color: var(--lyplus-text-primary) !important;
5234
5751
  transition: background-color 120ms ease-out !important;
@@ -5401,6 +5918,49 @@ AmLyrics$1.styles = i$3 `
5401
5918
  gap: 4px;
5402
5919
  }
5403
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
+
5404
5964
  .control-button {
5405
5965
  background: transparent;
5406
5966
  border: 1px solid rgba(255, 255, 255, 0.3);
@@ -5409,7 +5969,10 @@ AmLyrics$1.styles = i$3 `
5409
5969
  font-size: 0.8em;
5410
5970
  color: rgba(255, 255, 255, 0.6);
5411
5971
  cursor: pointer;
5412
- transition: all 0.2s;
5972
+ transition:
5973
+ color 0.2s,
5974
+ border-color 0.2s,
5975
+ background-color 0.2s;
5413
5976
  font-weight: normal;
5414
5977
  }
5415
5978
 
@@ -5547,137 +6110,125 @@ AmLyrics$1.styles = i$3 `
5547
6110
  KEYFRAME ANIMATIONS
5548
6111
  ========================================================================== */
5549
6112
 
6113
+ @keyframes source-switch-spin {
6114
+ to {
6115
+ transform: rotate(360deg);
6116
+ }
6117
+ }
6118
+
5550
6119
  /* Wipe animation for syllables */
5551
6120
  @keyframes wipe {
5552
6121
  from {
5553
- background-size:
5554
- 0.75em 100%,
5555
- 0% 100%;
5556
- background-position:
5557
- -0.375em 0%,
5558
- 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;
5559
6135
  }
5560
6136
  to {
5561
- background-size:
5562
- 0.75em 100%,
5563
- 100% 100%;
5564
- background-position:
5565
- calc(100% + 0.375em) 0%,
5566
- left;
6137
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6138
+ background-position: left;
5567
6139
  }
5568
6140
  }
5569
6141
 
5570
6142
  @keyframes start-wipe {
5571
6143
  0% {
5572
- background-size:
5573
- 0.75em 100%,
5574
- 0% 100%;
5575
- background-position:
5576
- -0.75em 0%,
5577
- -0.375em 0%;
6144
+ background-size: 0% 100%;
6145
+ background-position: left;
5578
6146
  }
5579
6147
  100% {
5580
- background-size:
5581
- 0.75em 100%,
5582
- 100% 100%;
5583
- background-position:
5584
- calc(100% + 0.375em) 0%,
5585
- left;
6148
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6149
+ background-position: left;
5586
6150
  }
5587
6151
  }
5588
6152
 
5589
6153
  @keyframes wipe-rtl {
5590
6154
  from {
5591
- background-size:
5592
- 0.75em 100%,
5593
- 0% 100%;
5594
- background-position:
5595
- calc(100% + 0.375em) 0%,
5596
- calc(100% + 0.36em) 0%;
6155
+ background-size: 0% 100%;
6156
+ background-position: right 0%;
5597
6157
  }
5598
6158
  to {
5599
- background-size:
5600
- 0.75em 100%,
5601
- 100% 100%;
5602
- background-position:
5603
- -0.75em 0%,
5604
- 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%;
5605
6172
  }
5606
6173
  }
5607
6174
 
5608
6175
  @keyframes start-wipe-rtl {
5609
6176
  0% {
5610
- background-size:
5611
- 0.75em 100%,
5612
- 0% 100%;
5613
- background-position:
5614
- calc(100% + 0.75em) 0%,
5615
- calc(100% + 0.5em) 0%;
6177
+ background-size: 0% 100%;
6178
+ background-position: right 0%;
5616
6179
  }
5617
6180
  100% {
5618
- background-size:
5619
- 0.75em 100%,
5620
- 100% 100%;
5621
- background-position:
5622
- -0.75em 0%,
5623
- right 0%;
6181
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
6182
+ background-position: right 0%;
5624
6183
  }
5625
6184
  }
5626
6185
 
5627
6186
  @keyframes pre-wipe-universal {
5628
6187
  from {
5629
- background-size:
5630
- 0.75em 100%,
5631
- 0% 100%;
5632
- background-position:
5633
- -0.75em 0%,
5634
- left;
6188
+ background-size: 0% 100%;
6189
+ background-position: left;
5635
6190
  }
5636
6191
  to {
5637
- background-size:
5638
- 0.75em 100%,
5639
- 0% 100%;
5640
- background-position:
5641
- -0.375em 0%,
5642
- left;
6192
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
6193
+ background-position: left;
5643
6194
  }
5644
6195
  }
5645
6196
 
5646
6197
  @keyframes pre-wipe-universal-rtl {
5647
6198
  from {
5648
- background-size:
5649
- 0.75em 100%,
5650
- 0% 100%;
5651
- background-position:
5652
- calc(100% + 0.75em) 0%,
5653
- 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%;
5654
6212
  }
5655
6213
  to {
5656
- background-size:
5657
- 0.75em 100%,
5658
- 0% 100%;
5659
- background-position:
5660
- calc(100% + 0.375em) 0%,
5661
- 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%;
5662
6217
  }
5663
6218
  }
5664
6219
 
5665
- @keyframes pre-wipe-char {
6220
+ @keyframes wipe-word-from-pre {
5666
6221
  from {
5667
- background-size:
5668
- 0.75em 100%,
5669
- 0% 100%;
5670
- background-position:
5671
- -0.75em 0%,
5672
- left;
6222
+ background-size: var(--pre-wipe-word-size, var(--wipe-gradient-width))
6223
+ 100%;
6224
+ background-position: var(--char-wipe-position, left) 0%;
5673
6225
  }
5674
6226
  to {
5675
- background-size:
5676
- 0.75em 100%,
5677
- 0% 100%;
5678
- background-position:
5679
- -0.375em 0%,
5680
- 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%;
5681
6232
  }
5682
6233
  }
5683
6234
 
@@ -5771,6 +6322,15 @@ AmLyrics$1.styles = i$3 `
5771
6322
  }
5772
6323
  }
5773
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
+
5774
6334
  @keyframes grow-static {
5775
6335
  0%,
5776
6336
  100% {