@uimaxbai/am-lyrics 1.2.6 → 1.2.8

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,17 +322,17 @@ class GoogleService {
322
322
  }
323
323
  }
324
324
 
325
- const VERSION = '1.2.6';
325
+ const VERSION = '1.2.8';
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;
329
329
  const PRE_SCROLL_LEAD_MS = 500;
330
- const PRE_SCROLL_LEAD_SHORT_MS = 150;
330
+ const PRE_SCROLL_LEAD_SHORT_MS = 350;
331
331
  const SCROLL_ANIMATION_DURATION_MS = 280;
332
332
  const SCROLL_DELAY_INCREMENT_MS = 24;
333
333
  const GAP_PULSE_DURATION_MS = 4000;
334
334
  const GAP_PULSE_CYCLE_MS = GAP_PULSE_DURATION_MS * 2;
335
- const GAP_EXIT_LEAD_MS = 360;
335
+ const GAP_EXIT_LEAD_MS = 600;
336
336
  const GAP_MIN_SCALE = 0.85;
337
337
  /**
338
338
  * Fetch with an automatic timeout via AbortSignal.
@@ -395,6 +395,15 @@ let AmLyrics$1 = class AmLyrics extends i {
395
395
  this.isClickSeeking = false;
396
396
  // Cached DOM elements for animation updates
397
397
  this.cachedLyricsLines = [];
398
+ // Cached line and gap element maps for fast lookup
399
+ this.lineElementCache = new Map();
400
+ this.gapElementCache = new Map();
401
+ // Cached gap computation results
402
+ this.cachedAllGaps = [];
403
+ // Cached isUnsynced flag
404
+ this.cachedIsUnsynced = false;
405
+ // Cached pre-computed line data for render
406
+ this.cachedLineData = null;
398
407
  // Active line tracking
399
408
  this.activeLineIds = new Set();
400
409
  this.currentPrimaryActiveLine = null;
@@ -486,6 +495,25 @@ let AmLyrics$1 = class AmLyrics extends i {
486
495
  get currentTime() {
487
496
  return this._currentTime;
488
497
  }
498
+ _updateFooter() {
499
+ const footer = this.shadowRoot?.querySelector('.lyrics-footer');
500
+ if (!footer)
501
+ return;
502
+ const switchBtn = footer.querySelector('.source-switch-btn');
503
+ const svgEl = footer.querySelector('.source-switch-svg');
504
+ const labelEl = footer.querySelector('.source-switch-label');
505
+ if (switchBtn) {
506
+ switchBtn.disabled = this.isFetchingAlternatives;
507
+ }
508
+ if (svgEl) {
509
+ svgEl.setAttribute('style', `margin-right: 4px; ${this.isFetchingAlternatives ? 'animation: spin 1s linear infinite;' : ''}`);
510
+ }
511
+ if (labelEl) {
512
+ labelEl.textContent = this.isFetchingAlternatives
513
+ ? 'Switching...'
514
+ : 'Switch';
515
+ }
516
+ }
489
517
  connectedCallback() {
490
518
  super.connectedCallback();
491
519
  this.fetchLyrics();
@@ -533,6 +561,7 @@ let AmLyrics$1 = class AmLyrics extends i {
533
561
  this.currentSourceIndex = 0;
534
562
  this.isFetchingAlternatives = false;
535
563
  this.hasFetchedAllProviders = false;
564
+ this._updateFooter();
536
565
  try {
537
566
  const resolvedMetadata = await this.resolveSongMetadata();
538
567
  // If a newer fetch was triggered while we awaited, bail out
@@ -585,6 +614,7 @@ let AmLyrics$1 = class AmLyrics extends i {
585
614
  collectedSources.some(s => s.source === 'LRCLIB' ||
586
615
  s.source === 'Tidal' ||
587
616
  s.source === 'Genius');
617
+ this._updateFooter();
588
618
  if (collectedSources.length > 0) {
589
619
  this.availableSources = AmLyrics.mergeAndSortSources(collectedSources);
590
620
  this.currentSourceIndex = 0;
@@ -695,6 +725,7 @@ let AmLyrics$1 = class AmLyrics extends i {
695
725
  return;
696
726
  if (!this.hasFetchedAllProviders) {
697
727
  this.isFetchingAlternatives = true;
728
+ this._updateFooter();
698
729
  try {
699
730
  const resolvedMetadata = await this.resolveSongMetadata();
700
731
  if (resolvedMetadata?.metadata) {
@@ -734,6 +765,7 @@ let AmLyrics$1 = class AmLyrics extends i {
734
765
  finally {
735
766
  this.hasFetchedAllProviders = true;
736
767
  this.isFetchingAlternatives = false;
768
+ this._updateFooter();
737
769
  }
738
770
  }
739
771
  if (this.availableSources.length > 1) {
@@ -1786,13 +1818,11 @@ let AmLyrics$1 = class AmLyrics extends i {
1786
1818
  // Reset animation if active lines change or if we skip time.
1787
1819
  const linesChanged = !AmLyrics.arraysEqual(newActiveLines, oldActiveLines);
1788
1820
  if (linesChanged || isSeek) {
1789
- // Imperatively manage 'active' class so that scroll-animate and other
1790
- // imperative classes are never clobbered.
1791
1821
  if (this.lyricsContainer) {
1792
1822
  // Remove 'active' from lines that are no longer active
1793
1823
  for (const lineIndex of oldActiveLines) {
1794
1824
  if (!newActiveLines.includes(lineIndex)) {
1795
- const lineElement = this.lyricsContainer.querySelector(`#lyrics-line-${lineIndex}`);
1825
+ const lineElement = this._getLineElement(lineIndex);
1796
1826
  if (lineElement) {
1797
1827
  lineElement.classList.remove('active');
1798
1828
  AmLyrics.resetSyllables(lineElement);
@@ -1802,10 +1832,10 @@ let AmLyrics$1 = class AmLyrics extends i {
1802
1832
  // Add 'active' to newly active lines
1803
1833
  for (const lineIndex of newActiveLines) {
1804
1834
  if (!oldActiveLines.includes(lineIndex)) {
1805
- const lineElement = this.lyricsContainer.querySelector(`#lyrics-line-${lineIndex}`);
1835
+ const lineElement = this._getLineElement(lineIndex);
1806
1836
  if (lineElement) {
1807
1837
  lineElement.classList.add('active');
1808
- lineElement.classList.remove('pre-active'); // Cleanup pre-active when fully active
1838
+ lineElement.classList.remove('pre-active');
1809
1839
  }
1810
1840
  }
1811
1841
  }
@@ -1814,14 +1844,12 @@ let AmLyrics$1 = class AmLyrics extends i {
1814
1844
  }
1815
1845
  }
1816
1846
  this.startAnimationFromTime(newTime);
1817
- // Trigger scroll imperatively (was previously in updated() via @state)
1818
1847
  this._handleActiveLineScroll(oldActiveLines, isSeek);
1819
1848
  }
1820
- // YouLyPlus-style syllable animation updates
1821
1849
  if (this.lyricsContainer) {
1822
- // Update syllables in active lines
1850
+ // Update syllables in active lines using cached elements
1823
1851
  for (const lineIndex of this.activeLineIndices) {
1824
- const lineElement = this.lyricsContainer.querySelector(`#lyrics-line-${lineIndex}`);
1852
+ const lineElement = this._getLineElement(lineIndex);
1825
1853
  if (lineElement) {
1826
1854
  AmLyrics.updateSyllablesForLine(lineElement, newTime);
1827
1855
  }
@@ -1831,60 +1859,81 @@ let AmLyrics$1 = class AmLyrics extends i {
1831
1859
  activeGaps.forEach(gapLine => {
1832
1860
  AmLyrics.updateSyllablesForLine(gapLine, newTime);
1833
1861
  });
1834
- // Imperatively manage gap active state (template doesn't re-render on time changes)
1835
- const allGaps = this.lyricsContainer.querySelectorAll('.lyrics-gap');
1836
- allGaps.forEach(gap => {
1837
- const gapStartTime = parseFloat(gap.getAttribute('data-start-time') || '0');
1838
- const gapEndTime = parseFloat(gap.getAttribute('data-end-time') || '0');
1839
- const shouldBeActive = newTime >= gapStartTime && newTime < gapEndTime;
1840
- const isActive = gap.classList.contains('active');
1841
- const isExiting = gap.classList.contains('gap-exiting');
1842
- // Start exit animation early so it completes before the next lyric
1843
- const exitLeadMs = GAP_EXIT_LEAD_MS;
1844
- const shouldStartExiting = isActive && !isExiting && newTime >= gapEndTime - exitLeadMs;
1845
- if (shouldBeActive && !isActive && !isExiting) {
1846
- // Entering gap: remove any leftover exit state, add active
1847
- gap.classList.remove('gap-exiting');
1848
- gap.classList.add('active');
1849
- // Mark dots whose time has already passed as finished, and
1850
- // trigger highlight on the dot currently in its time window
1851
- // so the first dot always lights up even on late load.
1852
- const dotSyllables = gap.querySelectorAll('.lyrics-syllable');
1853
- dotSyllables.forEach(dot => {
1854
- const dotStart = parseFloat(dot.getAttribute('data-start-time') || '0');
1855
- const dotEnd = parseFloat(dot.getAttribute('data-end-time') || '0');
1856
- if (newTime > dotEnd) {
1857
- dot.classList.add('finished');
1858
- // Also ensure the highlight + animation fired so CSS state is correct
1859
- if (!dot.classList.contains('highlight')) {
1862
+ // Imperatively manage gap active state
1863
+ if (this.gapElementCache.size > 0) {
1864
+ for (const [, gap] of this.gapElementCache) {
1865
+ const gapStartTime = parseFloat(gap.getAttribute('data-start-time') || '0');
1866
+ const gapEndTime = parseFloat(gap.getAttribute('data-end-time') || '0');
1867
+ const shouldBeActive = newTime >= gapStartTime && newTime < gapEndTime;
1868
+ const isActive = gap.classList.contains('active');
1869
+ const isExiting = gap.classList.contains('gap-exiting');
1870
+ const exitLeadMs = GAP_EXIT_LEAD_MS;
1871
+ const shouldStartExiting = isActive && !isExiting && newTime >= gapEndTime - exitLeadMs;
1872
+ if (shouldBeActive && !isActive && !isExiting) {
1873
+ gap.classList.remove('gap-exiting');
1874
+ gap.classList.add('active');
1875
+ const dotSyllables = gap.querySelectorAll('.lyrics-syllable');
1876
+ dotSyllables.forEach(dot => {
1877
+ const dotStart = parseFloat(dot.getAttribute('data-start-time') || '0');
1878
+ const dotEnd = parseFloat(dot.getAttribute('data-end-time') || '0');
1879
+ if (newTime > dotEnd) {
1880
+ dot.classList.add('finished');
1881
+ if (!dot.classList.contains('highlight')) {
1882
+ AmLyrics.updateSyllableAnimation(dot);
1883
+ }
1884
+ }
1885
+ else if (newTime >= dotStart && newTime <= dotEnd) {
1860
1886
  AmLyrics.updateSyllableAnimation(dot);
1861
1887
  }
1862
- }
1863
- else if (newTime >= dotStart && newTime <= dotEnd) {
1864
- // Currently within this dot's window — trigger its highlight
1865
- AmLyrics.updateSyllableAnimation(dot);
1866
- }
1867
- });
1868
- }
1869
- else if (shouldStartExiting) {
1870
- // Exiting gap: keep visible while dots animate out
1871
- gap.classList.add('gap-exiting');
1872
- gap.classList.remove('active');
1873
- // After exit animation completes, remove gap-exiting to collapse
1874
- setTimeout(() => {
1888
+ });
1889
+ }
1890
+ else if (shouldStartExiting) {
1891
+ gap.classList.add('gap-exiting');
1892
+ gap.classList.remove('active');
1893
+ setTimeout(() => {
1894
+ gap.classList.remove('gap-exiting');
1895
+ }, GAP_EXIT_LEAD_MS);
1896
+ }
1897
+ else if (isActive && !shouldBeActive) {
1898
+ gap.classList.remove('active');
1875
1899
  gap.classList.remove('gap-exiting');
1876
- }, GAP_EXIT_LEAD_MS);
1877
- }
1878
- else if (isActive && !shouldBeActive) {
1879
- // NEW: Immediate cleanup if we seeked out of valid range
1880
- gap.classList.remove('active');
1881
- gap.classList.remove('gap-exiting');
1882
- }
1883
- else if (isExiting && newTime < gapEndTime - exitLeadMs) {
1884
- // NEW: Cleanup exiting state if we seeked backwards before exit window
1885
- gap.classList.remove('gap-exiting');
1900
+ }
1901
+ else if (isExiting && newTime < gapEndTime - exitLeadMs) {
1902
+ gap.classList.remove('gap-exiting');
1903
+ }
1886
1904
  }
1887
- });
1905
+ }
1906
+ else if (this.lyricsContainer) {
1907
+ // Fallback: no cache yet, use querySelectorAll
1908
+ const allGaps = this.lyricsContainer.querySelectorAll('.lyrics-gap');
1909
+ allGaps.forEach(gap => {
1910
+ const gapStartTime = parseFloat(gap.getAttribute('data-start-time') || '0');
1911
+ const gapEndTime = parseFloat(gap.getAttribute('data-end-time') || '0');
1912
+ const shouldBeActive = newTime >= gapStartTime && newTime < gapEndTime;
1913
+ const isActive = gap.classList.contains('active');
1914
+ const isExiting = gap.classList.contains('gap-exiting');
1915
+ const exitLeadMs = GAP_EXIT_LEAD_MS;
1916
+ const shouldStartExiting = isActive && !isExiting && newTime >= gapEndTime - exitLeadMs;
1917
+ if (shouldBeActive && !isActive && !isExiting) {
1918
+ gap.classList.remove('gap-exiting');
1919
+ gap.classList.add('active');
1920
+ }
1921
+ else if (shouldStartExiting) {
1922
+ gap.classList.add('gap-exiting');
1923
+ gap.classList.remove('active');
1924
+ setTimeout(() => {
1925
+ gap.classList.remove('gap-exiting');
1926
+ }, GAP_EXIT_LEAD_MS);
1927
+ }
1928
+ else if (isActive && !shouldBeActive) {
1929
+ gap.classList.remove('active');
1930
+ gap.classList.remove('gap-exiting');
1931
+ }
1932
+ else if (isExiting && newTime < gapEndTime - exitLeadMs) {
1933
+ gap.classList.remove('gap-exiting');
1934
+ }
1935
+ });
1936
+ }
1888
1937
  // Track instrumental gap state
1889
1938
  const currentGap = this.findInstrumentalGapAt(newTime);
1890
1939
  if (currentGap) {
@@ -1902,25 +1951,23 @@ let AmLyrics$1 = class AmLyrics extends i {
1902
1951
  for (let i = 0; i < this.lyrics.length; i += 1) {
1903
1952
  const line = this.lyrics[i];
1904
1953
  const timeUntilStart = line.timestamp - newTime;
1905
- const nextLineEl = this.lyricsContainer.querySelector(`#lyrics-line-${i}`);
1954
+ const nextLineEl = this._getLineElement(i);
1906
1955
  const isBackToBack = this.activeLineIndices.length > 0;
1907
1956
  const leadTime = isBackToBack
1908
1957
  ? PRE_SCROLL_LEAD_SHORT_MS
1909
1958
  : PRE_SCROLL_LEAD_MS;
1910
1959
  if (timeUntilStart > leadTime) {
1911
- break; // Lines are ordered by timestamp, no need to check further
1960
+ break;
1912
1961
  }
1913
1962
  if (timeUntilStart > 0 && timeUntilStart <= leadTime) {
1914
- // Time to pre-scroll and pre-activate!
1915
1963
  if (nextLineEl) {
1916
1964
  preActiveLineIndex = i;
1917
1965
  if (!isBackToBack) {
1918
- // Apply unblur & zoom effect ahead of lyric start only if no line is currently active
1919
1966
  nextLineEl.classList.add('pre-active');
1920
1967
  }
1921
1968
  this.clearPreActiveClasses(i);
1922
1969
  const slowScrollDuration = Math.max(SCROLL_ANIMATION_DURATION_MS, timeUntilStart);
1923
- this.focusLine(nextLineEl, false, slowScrollDuration, !!currentGap);
1970
+ this.focusLine(nextLineEl, false, isBackToBack ? 500 : slowScrollDuration);
1924
1971
  }
1925
1972
  break;
1926
1973
  }
@@ -1931,6 +1978,9 @@ let AmLyrics$1 = class AmLyrics extends i {
1931
1978
  }
1932
1979
  updated(changedProperties) {
1933
1980
  if (changedProperties.has('lyrics')) {
1981
+ this._invalidateCaches();
1982
+ this._ensureLineDataCache();
1983
+ this._updateCachedIsUnsynced();
1934
1984
  // Recalculate timing data for accurate animations whenever lyrics change
1935
1985
  this._updateCharTimingData();
1936
1986
  // Apply 'active' classes imperatively after lyrics first render,
@@ -1939,7 +1989,7 @@ let AmLyrics$1 = class AmLyrics extends i {
1939
1989
  if (this.lyricsContainer && this.lyrics) {
1940
1990
  const activeLines = this.findActiveLineIndices(this.currentTime);
1941
1991
  for (const lineIndex of activeLines) {
1942
- const lineEl = this.lyricsContainer.querySelector(`#lyrics-line-${lineIndex}`);
1992
+ const lineEl = this._getLineElement(lineIndex);
1943
1993
  if (lineEl)
1944
1994
  lineEl.classList.add('active');
1945
1995
  }
@@ -1955,7 +2005,7 @@ let AmLyrics$1 = class AmLyrics extends i {
1955
2005
  this.backgroundWordProgress.clear();
1956
2006
  this.mainWordAnimations.clear();
1957
2007
  this.backgroundWordAnimations.clear();
1958
- this.isUserScrolling = false;
2008
+ this.setUserScrolling(false);
1959
2009
  // Cancel any running animations
1960
2010
  if (this.animationFrameId) {
1961
2011
  cancelAnimationFrame(this.animationFrameId);
@@ -1995,7 +2045,7 @@ let AmLyrics$1 = class AmLyrics extends i {
1995
2045
  const targetLineIndex = this.getPrimaryActiveLineIndex(this.activeLineIndices);
1996
2046
  if (targetLineIndex === null)
1997
2047
  return;
1998
- const targetLine = this.lyricsContainer.querySelector(`#lyrics-line-${targetLineIndex}`);
2048
+ const targetLine = this._getLineElement(targetLineIndex);
1999
2049
  if (targetLine) {
2000
2050
  this.focusLine(targetLine, forceScroll);
2001
2051
  }
@@ -2013,9 +2063,144 @@ let AmLyrics$1 = class AmLyrics extends i {
2013
2063
  }
2014
2064
  return 0;
2015
2065
  }
2066
+ _rebuildDomCache() {
2067
+ if (!this.lyricsContainer)
2068
+ return;
2069
+ this.lineElementCache.clear();
2070
+ this.gapElementCache.clear();
2071
+ if (!this.lyrics)
2072
+ return;
2073
+ for (let i = 0; i < this.lyrics.length; i += 1) {
2074
+ const lineEl = this.lyricsContainer.querySelector(`#lyrics-line-${i}`);
2075
+ if (lineEl)
2076
+ this.lineElementCache.set(i, lineEl);
2077
+ const gapEl = this.lyricsContainer.querySelector(`#gap-${i}`);
2078
+ if (gapEl)
2079
+ this.gapElementCache.set(i, gapEl);
2080
+ }
2081
+ }
2082
+ _getLineElement(index) {
2083
+ const cached = this.lineElementCache.get(index);
2084
+ if (cached)
2085
+ return cached;
2086
+ if (!this.lyricsContainer)
2087
+ return null;
2088
+ const el = this.lyricsContainer.querySelector(`#lyrics-line-${index}`);
2089
+ if (el)
2090
+ this.lineElementCache.set(index, el);
2091
+ return el;
2092
+ }
2093
+ _getGapElement(index) {
2094
+ const cached = this.gapElementCache.get(index);
2095
+ if (cached)
2096
+ return cached;
2097
+ if (!this.lyricsContainer)
2098
+ return null;
2099
+ const el = this.lyricsContainer.querySelector(`#gap-${index}`);
2100
+ if (el)
2101
+ this.gapElementCache.set(index, el);
2102
+ return el;
2103
+ }
2104
+ _invalidateCaches() {
2105
+ this.cachedAllGaps = [];
2106
+ this.cachedIsUnsynced = false;
2107
+ this.cachedLineData = null;
2108
+ this.lineElementCache.clear();
2109
+ this.gapElementCache.clear();
2110
+ }
2111
+ _updateCachedIsUnsynced() {
2112
+ this.cachedIsUnsynced =
2113
+ this.lyrics && this.lyrics.length > 0
2114
+ ? this.lyrics.every(l => l.timestamp === 0 && l.endtime === 0)
2115
+ : false;
2116
+ }
2117
+ _ensureLineDataCache() {
2118
+ if (this.cachedLineData || !this.lyrics)
2119
+ return;
2120
+ this.cachedLineData = this.lyrics.map(line => {
2121
+ const wordGroups = [];
2122
+ for (const syllable of line.text) {
2123
+ if (syllable.part && wordGroups.length > 0) {
2124
+ wordGroups[wordGroups.length - 1].push(syllable);
2125
+ }
2126
+ else {
2127
+ wordGroups.push([syllable]);
2128
+ }
2129
+ }
2130
+ const groupGrowable = new Array(wordGroups.length).fill(false);
2131
+ const groupGlowing = new Array(wordGroups.length).fill(false);
2132
+ const vwFullText = new Array(wordGroups.length).fill('');
2133
+ const vwFullDuration = new Array(wordGroups.length).fill(0);
2134
+ const vwCharOffset = new Array(wordGroups.length).fill(0);
2135
+ const vwStartMs = new Array(wordGroups.length).fill(0);
2136
+ const vwEndMs = new Array(wordGroups.length).fill(0);
2137
+ let vwStart = 0;
2138
+ while (vwStart < wordGroups.length) {
2139
+ let vwEnd = vwStart;
2140
+ while (vwEnd < wordGroups.length - 1) {
2141
+ const grp = wordGroups[vwEnd];
2142
+ const lastText = grp[grp.length - 1].text;
2143
+ if (/\s$/.test(lastText))
2144
+ break;
2145
+ vwEnd += 1;
2146
+ }
2147
+ const combinedText = wordGroups
2148
+ .slice(vwStart, vwEnd + 1)
2149
+ .flatMap(g => g.map(s => s.text))
2150
+ .join('')
2151
+ .trim();
2152
+ const combinedStart = wordGroups[vwStart][0].timestamp;
2153
+ const lastGrp = wordGroups[vwEnd];
2154
+ const combinedEnd = lastGrp[lastGrp.length - 1].endtime;
2155
+ const combinedDuration = combinedEnd - combinedStart;
2156
+ const isCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(combinedText);
2157
+ const isRTL = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF]/.test(combinedText);
2158
+ const hasHyphen = combinedText.includes('-');
2159
+ const wordLen = combinedText.length;
2160
+ let isGrowableVW = !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 12;
2161
+ if (isGrowableVW) {
2162
+ if (wordLen < 3) {
2163
+ isGrowableVW =
2164
+ combinedDuration >= 1110 && combinedDuration >= wordLen * 550;
2165
+ }
2166
+ else {
2167
+ isGrowableVW =
2168
+ combinedDuration >= 850 && combinedDuration >= wordLen * 200;
2169
+ }
2170
+ }
2171
+ const isGlowingVW = isGrowableVW &&
2172
+ combinedDuration >= 1000 &&
2173
+ combinedDuration >= combinedText.length * 250;
2174
+ let charOff = 0;
2175
+ for (let gi = vwStart; gi <= vwEnd; gi += 1) {
2176
+ groupGrowable[gi] = isGrowableVW;
2177
+ groupGlowing[gi] = isGlowingVW;
2178
+ vwFullText[gi] = combinedText;
2179
+ vwFullDuration[gi] = combinedDuration;
2180
+ vwCharOffset[gi] = charOff;
2181
+ vwStartMs[gi] = combinedStart;
2182
+ vwEndMs[gi] = combinedEnd;
2183
+ const grpText = wordGroups[gi].map(s => s.text).join('');
2184
+ charOff += grpText.replace(/\s/g, '').length;
2185
+ }
2186
+ vwStart = vwEnd + 1;
2187
+ }
2188
+ return {
2189
+ wordGroups,
2190
+ groupGrowable,
2191
+ groupGlowing,
2192
+ vwFullText,
2193
+ vwFullDuration,
2194
+ vwCharOffset,
2195
+ vwStartMs,
2196
+ vwEndMs,
2197
+ };
2198
+ });
2199
+ }
2016
2200
  _updateCharTimingData() {
2017
2201
  if (!this.shadowRoot)
2018
2202
  return;
2203
+ this._rebuildDomCache();
2019
2204
  // Get the computed font from the first syllable to ensure accuracy
2020
2205
  const referenceSyllable = this.shadowRoot.querySelector('.lyrics-syllable');
2021
2206
  if (!referenceSyllable)
@@ -2128,21 +2313,29 @@ let AmLyrics$1 = class AmLyrics extends i {
2128
2313
  this.scrollToActiveLineYouLy(lineElement, forceScroll, scrollDuration);
2129
2314
  }
2130
2315
  }
2316
+ setUserScrolling(value) {
2317
+ this.isUserScrolling = value;
2318
+ if (value) {
2319
+ this.lyricsContainer?.classList.add('user-scrolling');
2320
+ }
2321
+ else {
2322
+ this.lyricsContainer?.classList.remove('user-scrolling');
2323
+ }
2324
+ }
2131
2325
  handleUserScroll() {
2132
2326
  // Ignore programmatic scrolls and click-seek scrolls
2133
2327
  if (this.isProgrammaticScroll || this.isClickSeeking) {
2134
2328
  return;
2135
2329
  }
2136
2330
  // Mark that user is currently scrolling
2137
- this.isUserScrolling = true;
2138
- this.lyricsContainer?.classList.add('user-scrolling');
2331
+ this.setUserScrolling(true);
2139
2332
  // Clear any existing timeout
2140
2333
  if (this.userScrollTimeoutId) {
2141
2334
  clearTimeout(this.userScrollTimeoutId);
2142
2335
  }
2143
2336
  // Set timeout to re-enable auto-scroll after 2 seconds of no scrolling
2144
2337
  this.userScrollTimeoutId = window.setTimeout(() => {
2145
- this.isUserScrolling = false;
2338
+ this.setUserScrolling(false);
2146
2339
  this.userScrollTimeoutId = undefined;
2147
2340
  // Optionally scroll back to current active line when re-enabling auto-scroll
2148
2341
  if (this.activeLineIndices.length > 0) {
@@ -2151,25 +2344,23 @@ let AmLyrics$1 = class AmLyrics extends i {
2151
2344
  }, 2000);
2152
2345
  }
2153
2346
  findActiveLineIndices(time) {
2154
- if (!this.lyrics)
2347
+ if (!this.lyrics || this.lyrics.length === 0)
2155
2348
  return [];
2156
2349
  const activeLines = [];
2157
2350
  for (let i = 0; i < this.lyrics.length; i += 1) {
2158
2351
  const line = this.lyrics[i];
2159
2352
  let effectiveEndTime = line.endtime;
2160
- // Extend the "active" highlight window to abut the next line,
2161
- // leaving a 500ms gap for breathing/scrolling
2162
2353
  if (i < this.lyrics.length - 1) {
2163
2354
  const nextLineStart = this.lyrics[i + 1].timestamp;
2164
2355
  const gapDuration = nextLineStart - line.endtime;
2165
- // If the gap is large enough to trigger the breathing dots,
2166
- // DO NOT extend the highlight. The text should dim when the dots appear.
2167
2356
  if (gapDuration < INSTRUMENTAL_THRESHOLD_MS) {
2168
2357
  if (effectiveEndTime < nextLineStart) {
2169
2358
  effectiveEndTime = Math.max(effectiveEndTime, nextLineStart - 500);
2170
2359
  }
2171
2360
  }
2172
2361
  }
2362
+ if (line.timestamp > time)
2363
+ break;
2173
2364
  if (time >= line.timestamp && time <= effectiveEndTime) {
2174
2365
  activeLines.push(i);
2175
2366
  }
@@ -2209,6 +2400,8 @@ let AmLyrics$1 = class AmLyrics extends i {
2209
2400
  * Used by the template to always render gap elements in the DOM.
2210
2401
  */
2211
2402
  findAllInstrumentalGaps() {
2403
+ if (this.cachedAllGaps.length > 0)
2404
+ return this.cachedAllGaps;
2212
2405
  if (!this.lyrics || this.lyrics.length === 0)
2213
2406
  return [];
2214
2407
  const gaps = [];
@@ -2227,6 +2420,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2227
2420
  gaps.push({ insertBeforeIndex: i + 1, gapStart, gapEnd });
2228
2421
  }
2229
2422
  }
2423
+ this.cachedAllGaps = gaps;
2230
2424
  return gaps;
2231
2425
  }
2232
2426
  startAnimationFromTime(time) {
@@ -2390,7 +2584,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2390
2584
  clearTimeout(this.userScrollTimeoutId);
2391
2585
  this.userScrollTimeoutId = undefined;
2392
2586
  }
2393
- this.isUserScrolling = false;
2587
+ this.setUserScrolling(false);
2394
2588
  // Reset active line tracking to prevent scroll fighting
2395
2589
  this.currentPrimaryActiveLine = null;
2396
2590
  this.lastPrimaryActiveLine = null;
@@ -2689,7 +2883,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2689
2883
  }
2690
2884
  this.lyricsContainer.classList.remove('not-focused', 'user-scrolling');
2691
2885
  this.isProgrammaticScroll = true;
2692
- this.isUserScrolling = false;
2886
+ this.setUserScrolling(false);
2693
2887
  if (this.userScrollTimeoutId) {
2694
2888
  clearTimeout(this.userScrollTimeoutId);
2695
2889
  this.userScrollTimeoutId = undefined;
@@ -2726,42 +2920,19 @@ let AmLyrics$1 = class AmLyrics extends i {
2726
2920
  // Use a Map to collect animations like YouLyPlus
2727
2921
  const charAnimationsMap = new Map();
2728
2922
  const styleUpdates = [];
2729
- // Step 1 & 2: Apply animations
2923
+ // Step 1: Grow Pass
2730
2924
  if (isGrowable && isFirstSyllable && allWordCharSpans.length > 0) {
2731
- // Glow AND wipe applied to ALL characters simultaneously from the first syllable
2732
- // This prevents CSS animation restarts because the `animation` property is set once.
2733
- const firstSyllableStartTime = parseFloat(syllable.getAttribute('data-start-time') || '0');
2734
- allWordCharSpans.forEach((span, charIndexInWord) => {
2925
+ const finalDuration = wordDurationMs;
2926
+ const baseDelayPerChar = finalDuration * 0.09;
2927
+ const growDurationMs = finalDuration * 1.5;
2928
+ allWordCharSpans.forEach(span => {
2735
2929
  const horizontalOffset = parseFloat(span.dataset.horizontalOffset || '0');
2736
2930
  const maxScale = span.dataset.maxScale || '1.1';
2737
2931
  const shadowIntensity = span.dataset.shadowIntensity || '0.6';
2738
2932
  const translateYPeak = span.dataset.translateYPeak || '-2';
2739
- const animationParts = [];
2740
- const parentSyllable = span.closest('.lyrics-syllable');
2741
- if (parentSyllable) {
2742
- const parentDuration = parseFloat(parentSyllable.getAttribute('data-duration') || '0');
2743
- const parentStartTime = parseFloat(parentSyllable.getAttribute('data-start-time') || '0');
2744
- const startPct = parseFloat(span.dataset.wipeStart || '0');
2745
- const durationPct = parseFloat(span.dataset.wipeDuration || '0');
2746
- const relativeStartOffset = Math.max(0, parentStartTime - firstSyllableStartTime);
2747
- const wipeDelay = relativeStartOffset + parentDuration * startPct;
2748
- const wipeDuration = parentDuration * durationPct;
2749
- const useStartAnimation = isFirstInContainer && charIndexInWord === 0;
2750
- let charWipeAnimation = 'wipe';
2751
- if (useStartAnimation)
2752
- charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
2753
- else
2754
- charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
2755
- // Blend word and syllable durations to let the gradient flow smoothly
2756
- // while still responding to syllable pacing (no strict exactness, just natural flow)
2757
- const growDelay = wipeDelay;
2758
- const growDurationMs = Math.max(600, wordDurationMs * 0.8 + parentDuration * 1.5);
2759
- animationParts.push(`grow-dynamic ${growDurationMs}ms ease-in-out ${growDelay}ms forwards`);
2760
- if (wipeDuration > 0) {
2761
- animationParts.push(`${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`);
2762
- }
2763
- }
2764
- charAnimationsMap.set(span, animationParts.join(', '));
2933
+ const syllableCharIndex = parseFloat(span.dataset.syllableCharIndex || '0');
2934
+ const growDelay = baseDelayPerChar * syllableCharIndex;
2935
+ charAnimationsMap.set(span, `grow-dynamic ${growDurationMs}ms ease-in-out ${growDelay}ms forwards`);
2765
2936
  styleUpdates.push({
2766
2937
  element: span,
2767
2938
  property: '--char-offset-x',
@@ -2784,26 +2955,8 @@ let AmLyrics$1 = class AmLyrics extends i {
2784
2955
  });
2785
2956
  });
2786
2957
  }
2787
- else if (isGrowable && !isFirstSyllable && charSpans.length > 0) {
2788
- // For subsequent syllables of a growable word:
2789
- // If they already have `grow-dynamic`, it means the first syllable correctly took care of BOTH grow and wipe!
2790
- // Otherwise, they scrubbed directly into this syllable, so let's at least do the wipe.
2791
- charSpans.forEach(span => {
2792
- const existingAnimation = charAnimationsMap.get(span) || span.style.animation || '';
2793
- if (existingAnimation.includes('grow-dynamic'))
2794
- return;
2795
- const startPct = parseFloat(span.dataset.wipeStart || '0');
2796
- const durationPct = parseFloat(span.dataset.wipeDuration || '0');
2797
- const wipeDelay = syllableDurationMs * startPct;
2798
- const wipeDuration = syllableDurationMs * durationPct;
2799
- const charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
2800
- if (wipeDuration > 0) {
2801
- charAnimationsMap.set(span, `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`);
2802
- }
2803
- });
2804
- }
2805
- else if (charSpans.length > 0) {
2806
- // Per-character wipe for non-growable words (matching YouLyPlus)
2958
+ // Step 2: Wipe Pass
2959
+ if (charSpans.length > 0) {
2807
2960
  charSpans.forEach((span, charIndex) => {
2808
2961
  const startPct = parseFloat(span.dataset.wipeStart || '0');
2809
2962
  const durationPct = parseFloat(span.dataset.wipeDuration || '0');
@@ -2817,8 +2970,26 @@ let AmLyrics$1 = class AmLyrics extends i {
2817
2970
  else {
2818
2971
  charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
2819
2972
  }
2973
+ const existingAnimation = charAnimationsMap.get(span) || span.style.animation || '';
2974
+ const animationParts = [];
2975
+ if (existingAnimation && existingAnimation.includes('grow-dynamic')) {
2976
+ animationParts.push(existingAnimation.split(',')[0].trim());
2977
+ }
2978
+ if (charIndex > 0) {
2979
+ const arrivalTime = span.dataset.preWipeArrival
2980
+ ? parseFloat(span.dataset.preWipeArrival)
2981
+ : wipeDelay;
2982
+ const constantDuration = parseFloat(span.dataset.preWipeDuration || '100');
2983
+ const animDelay = arrivalTime - constantDuration;
2984
+ if (constantDuration > 0) {
2985
+ animationParts.push(`pre-wipe-char ${constantDuration}ms linear ${animDelay}ms forwards`);
2986
+ }
2987
+ }
2820
2988
  if (wipeDuration > 0) {
2821
- charAnimationsMap.set(span, `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`);
2989
+ animationParts.push(`${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`);
2990
+ }
2991
+ if (animationParts.length > 0) {
2992
+ charAnimationsMap.set(span, animationParts.join(', '));
2822
2993
  }
2823
2994
  });
2824
2995
  }
@@ -3200,9 +3371,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3200
3371
  this.style.setProperty('--hover-background-color', this.hoverBackgroundColor);
3201
3372
  this.style.setProperty('--highlight-color', this.highlightColor);
3202
3373
  const sourceLabel = this.lyricsSource ?? 'Unavailable';
3203
- const isUnsynced = this.lyrics && this.lyrics.length > 0
3204
- ? this.lyrics.every(l => l.timestamp === 0 && l.endtime === 0)
3205
- : false;
3374
+ const isUnsynced = this.cachedIsUnsynced;
3206
3375
  const renderContent = () => {
3207
3376
  if (this.isLoading) {
3208
3377
  // Render stylized skeleton lines
@@ -3274,88 +3443,13 @@ let AmLyrics$1 = class AmLyrics extends i {
3274
3443
  // as the main vocal, so we intentionally do NOT render a separate
3275
3444
  // translation/romanization block for background — it would just duplicate
3276
3445
  // the main line's text.
3277
- // Group syllables by word: when part=true, append to previous word group
3278
- const wordGroups = [];
3279
- for (const syllable of line.text) {
3280
- if (syllable.part && wordGroups.length > 0) {
3281
- // Continuation of previous word
3282
- wordGroups[wordGroups.length - 1].push(syllable);
3283
- }
3284
- else {
3285
- // New word
3286
- wordGroups.push([syllable]);
3287
- }
3288
- }
3289
- // Pre-compute isGrowable per "visual word": adjacent groups whose text
3290
- // doesn't end with whitespace form one visual word (e.g. "a"+"live" = "alive").
3291
- // We evaluate growable on the combined text/duration, then propagate
3292
- // the result to each individual group so it renders through the
3293
- // single-syllable path (which supports char-level glow).
3294
- const groupGrowable = new Array(wordGroups.length).fill(false);
3295
- const groupGlowing = new Array(wordGroups.length).fill(false);
3296
- // Visual word info for growable char-level glow:
3297
- // Each group stores the combined visual word's text, duration, and
3298
- // the char offset of this group within the visual word.
3299
- const vwFullText = new Array(wordGroups.length).fill('');
3300
- const vwFullDuration = new Array(wordGroups.length).fill(0);
3301
- const vwCharOffset = new Array(wordGroups.length).fill(0);
3302
- const vwStartMs = new Array(wordGroups.length).fill(0);
3303
- const vwEndMs = new Array(wordGroups.length).fill(0);
3304
- {
3305
- let vwStart = 0;
3306
- while (vwStart < wordGroups.length) {
3307
- let vwEnd = vwStart;
3308
- while (vwEnd < wordGroups.length - 1) {
3309
- const grp = wordGroups[vwEnd];
3310
- const lastText = grp[grp.length - 1].text;
3311
- if (/\s$/.test(lastText))
3312
- break;
3313
- vwEnd += 1;
3314
- }
3315
- // Compute combined properties for this visual word
3316
- const combinedText = wordGroups
3317
- .slice(vwStart, vwEnd + 1)
3318
- .flatMap(g => g.map(s => s.text))
3319
- .join('')
3320
- .trim();
3321
- const combinedStart = wordGroups[vwStart][0].timestamp;
3322
- const lastGrp = wordGroups[vwEnd];
3323
- const combinedEnd = lastGrp[lastGrp.length - 1].endtime;
3324
- const combinedDuration = combinedEnd - combinedStart;
3325
- const isCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(combinedText);
3326
- const isRTL = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF]/.test(combinedText);
3327
- const hasHyphen = combinedText.includes('-');
3328
- const wordLen = combinedText.length;
3329
- let isGrowableVW = !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 12;
3330
- if (isGrowableVW) {
3331
- if (wordLen < 3) {
3332
- isGrowableVW =
3333
- combinedDuration >= 1110 && combinedDuration >= wordLen * 550;
3334
- }
3335
- else {
3336
- isGrowableVW =
3337
- combinedDuration >= 850 && combinedDuration >= wordLen * 200;
3338
- }
3339
- }
3340
- // Glow requirement (more strict)
3341
- const isGlowingVW = isGrowableVW &&
3342
- combinedDuration >= 1000 &&
3343
- combinedDuration >= combinedText.length * 250;
3344
- let charOff = 0;
3345
- for (let gi = vwStart; gi <= vwEnd; gi += 1) {
3346
- groupGrowable[gi] = isGrowableVW;
3347
- groupGlowing[gi] = isGlowingVW;
3348
- vwFullText[gi] = combinedText;
3349
- vwFullDuration[gi] = combinedDuration;
3350
- vwCharOffset[gi] = charOff;
3351
- vwStartMs[gi] = combinedStart;
3352
- vwEndMs[gi] = combinedEnd;
3353
- const grpText = wordGroups[gi].map(s => s.text).join('');
3354
- charOff += grpText.replace(/\s/g, '').length;
3355
- }
3356
- vwStart = vwEnd + 1;
3357
- }
3358
- }
3446
+ const lineData = this.cachedLineData?.[lineIndex];
3447
+ const wordGroups = lineData?.wordGroups ?? [];
3448
+ const groupGrowable = lineData?.groupGrowable ?? [];
3449
+ const groupGlowing = lineData?.groupGlowing ?? [];
3450
+ const vwFullText = lineData?.vwFullText ?? [];
3451
+ const vwFullDuration = lineData?.vwFullDuration ?? [];
3452
+ const vwCharOffset = lineData?.vwCharOffset ?? [];
3359
3453
  // Create main vocals using YouLyPlus syllable structure
3360
3454
  const mainVocalElement = b `<p class="main-vocal-container">
3361
3455
  ${wordGroups.map((group, groupIdx) => {
@@ -3589,9 +3683,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3589
3683
  <div
3590
3684
  class="lyrics-container ${isUnsynced
3591
3685
  ? 'is-unsynced'
3592
- : 'blur-inactive-enabled'} ${this.isUserScrolling
3593
- ? 'user-scrolling'
3594
- : ''}"
3686
+ : 'blur-inactive-enabled'}"
3595
3687
  >
3596
3688
  ${!this.isLoading && this.lyrics && this.lyrics.length > 0
3597
3689
  ? b `
@@ -3706,17 +3798,15 @@ let AmLyrics$1 = class AmLyrics extends i {
3706
3798
  !this.hasFetchedAllProviders
3707
3799
  ? b `
3708
3800
  <button
3709
- class="download-button"
3801
+ class="download-button source-switch-btn"
3710
3802
  title="Switch Lyrics Source"
3711
3803
  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;"
3712
3804
  @click=${this.switchSource}
3713
3805
  ?disabled=${this.isFetchingAlternatives}
3714
3806
  >
3715
3807
  <svg
3716
- style="margin-right: 4px; ${this
3717
- .isFetchingAlternatives
3718
- ? 'animation: spin 1s linear infinite;'
3719
- : ''}"
3808
+ class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
3809
+ style="margin-right: 4px;"
3720
3810
  xmlns="http://www.w3.org/2000/svg"
3721
3811
  width="12"
3722
3812
  height="12"
@@ -3726,7 +3816,6 @@ let AmLyrics$1 = class AmLyrics extends i {
3726
3816
  stroke-width="2"
3727
3817
  stroke-linecap="round"
3728
3818
  stroke-linejoin="round"
3729
- class="lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
3730
3819
  >
3731
3820
  ${this.isFetchingAlternatives
3732
3821
  ? w `<path
@@ -3737,9 +3826,11 @@ let AmLyrics$1 = class AmLyrics extends i {
3737
3826
  ><path d="m21 8-4-4-4 4"></path
3738
3827
  ><path d="M17 4v16"></path>`}
3739
3828
  </svg>
3740
- ${this.isFetchingAlternatives
3829
+ <span class="source-switch-label"
3830
+ >${this.isFetchingAlternatives
3741
3831
  ? 'Switching...'
3742
- : 'Switch'}
3832
+ : 'Switch'}</span
3833
+ >
3743
3834
  </button>
3744
3835
  `
3745
3836
  : ''}
@@ -4654,8 +4745,8 @@ AmLyrics$1.styles = i$3 `
4654
4745
  0.75em 100%,
4655
4746
  0% 100%;
4656
4747
  background-position:
4657
- -0.375em 0%,
4658
- left;
4748
+ -0.75em 0%,
4749
+ -0.375em 0%;
4659
4750
  }
4660
4751
  100% {
4661
4752
  background-size:
@@ -4749,7 +4840,7 @@ AmLyrics$1.styles = i$3 `
4749
4840
  0.75em 100%,
4750
4841
  0% 100%;
4751
4842
  background-position:
4752
- -0.85em 0%,
4843
+ -0.75em 0%,
4753
4844
  left;
4754
4845
  }
4755
4846
  to {
@@ -4757,7 +4848,7 @@ AmLyrics$1.styles = i$3 `
4757
4848
  0.75em 100%,
4758
4849
  0% 100%;
4759
4850
  background-position:
4760
- -0.85em 0%,
4851
+ -0.375em 0%,
4761
4852
  left;
4762
4853
  }
4763
4854
  }
@@ -5024,18 +5115,9 @@ __decorate([
5024
5115
  __decorate([
5025
5116
  r()
5026
5117
  ], AmLyrics$1.prototype, "currentSourceIndex", void 0);
5027
- __decorate([
5028
- r()
5029
- ], AmLyrics$1.prototype, "isFetchingAlternatives", void 0);
5030
- __decorate([
5031
- r()
5032
- ], AmLyrics$1.prototype, "hasFetchedAllProviders", void 0);
5033
5118
  __decorate([
5034
5119
  e('.lyrics-container')
5035
5120
  ], AmLyrics$1.prototype, "lyricsContainer", void 0);
5036
- __decorate([
5037
- r()
5038
- ], AmLyrics$1.prototype, "isUserScrolling", void 0);
5039
5121
 
5040
5122
  // This creates the React-usable component
5041
5123
  const AmLyrics = createComponent({