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