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