@uimaxbai/am-lyrics 1.2.7 → 1.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/react.js CHANGED
@@ -322,7 +322,7 @@ class GoogleService {
322
322
  }
323
323
  }
324
324
 
325
- const VERSION = '1.2.7';
325
+ const VERSION = '1.2.9';
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;
@@ -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,20 +1951,18 @@ 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);
@@ -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,10 +2045,23 @@ 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}`);
1999
- if (targetLine) {
2000
- this.focusLine(targetLine, forceScroll);
2048
+ const targetLine = this._getLineElement(targetLineIndex);
2049
+ if (!targetLine)
2050
+ return;
2051
+ // Only scroll snappily when lines are essentially back-to-back.
2052
+ // If there is any noticeable gap between them, scroll slower.
2053
+ let scrollDuration;
2054
+ const prevPrimaryIndex = AmLyrics.getLineIndexFromElement(this.currentPrimaryActiveLine);
2055
+ if (prevPrimaryIndex !== null &&
2056
+ targetLineIndex > prevPrimaryIndex &&
2057
+ this.lyrics) {
2058
+ const gap = this.lyrics[targetLineIndex].timestamp -
2059
+ this.lyrics[prevPrimaryIndex].endtime;
2060
+ if (gap > 200) {
2061
+ scrollDuration = Math.min(Math.max(gap * 0.6, SCROLL_ANIMATION_DURATION_MS), 2000);
2062
+ }
2001
2063
  }
2064
+ this.focusLine(targetLine, forceScroll, scrollDuration);
2002
2065
  }
2003
2066
  _getTextWidth(text, font) {
2004
2067
  if (!this._textWidthCanvas) {
@@ -2013,9 +2076,142 @@ let AmLyrics$1 = class AmLyrics extends i {
2013
2076
  }
2014
2077
  return 0;
2015
2078
  }
2079
+ _rebuildDomCache() {
2080
+ if (!this.lyricsContainer)
2081
+ return;
2082
+ this.lineElementCache.clear();
2083
+ this.gapElementCache.clear();
2084
+ if (!this.lyrics)
2085
+ return;
2086
+ for (let i = 0; i < this.lyrics.length; i += 1) {
2087
+ const lineEl = this.lyricsContainer.querySelector(`#lyrics-line-${i}`);
2088
+ if (lineEl)
2089
+ this.lineElementCache.set(i, lineEl);
2090
+ const gapEl = this.lyricsContainer.querySelector(`#gap-${i}`);
2091
+ if (gapEl)
2092
+ this.gapElementCache.set(i, gapEl);
2093
+ }
2094
+ }
2095
+ _getLineElement(index) {
2096
+ const cached = this.lineElementCache.get(index);
2097
+ if (cached)
2098
+ return cached;
2099
+ if (!this.lyricsContainer)
2100
+ return null;
2101
+ const el = this.lyricsContainer.querySelector(`#lyrics-line-${index}`);
2102
+ if (el)
2103
+ this.lineElementCache.set(index, el);
2104
+ return el;
2105
+ }
2106
+ _getGapElement(index) {
2107
+ const cached = this.gapElementCache.get(index);
2108
+ if (cached)
2109
+ return cached;
2110
+ if (!this.lyricsContainer)
2111
+ return null;
2112
+ const el = this.lyricsContainer.querySelector(`#gap-${index}`);
2113
+ if (el)
2114
+ this.gapElementCache.set(index, el);
2115
+ return el;
2116
+ }
2117
+ _invalidateCaches() {
2118
+ this.cachedAllGaps = [];
2119
+ this.cachedIsUnsynced = false;
2120
+ this.cachedLineData = null;
2121
+ this.lineElementCache.clear();
2122
+ this.gapElementCache.clear();
2123
+ }
2124
+ _updateCachedIsUnsynced() {
2125
+ this.cachedIsUnsynced =
2126
+ this.lyrics && this.lyrics.length > 0
2127
+ ? this.lyrics.every(l => l.timestamp === 0 && l.endtime === 0)
2128
+ : false;
2129
+ }
2130
+ _ensureLineDataCache() {
2131
+ if (this.cachedLineData || !this.lyrics)
2132
+ return;
2133
+ this.cachedLineData = this.lyrics.map(line => {
2134
+ const wordGroups = [];
2135
+ for (const syllable of line.text) {
2136
+ if (syllable.part && wordGroups.length > 0) {
2137
+ wordGroups[wordGroups.length - 1].push(syllable);
2138
+ }
2139
+ else {
2140
+ wordGroups.push([syllable]);
2141
+ }
2142
+ }
2143
+ const groupGrowable = new Array(wordGroups.length).fill(false);
2144
+ const groupGlowing = new Array(wordGroups.length).fill(false);
2145
+ const vwFullText = new Array(wordGroups.length).fill('');
2146
+ const vwFullDuration = new Array(wordGroups.length).fill(0);
2147
+ const vwCharOffset = new Array(wordGroups.length).fill(0);
2148
+ const vwStartMs = new Array(wordGroups.length).fill(0);
2149
+ const vwEndMs = new Array(wordGroups.length).fill(0);
2150
+ let vwStart = 0;
2151
+ while (vwStart < wordGroups.length) {
2152
+ let vwEnd = vwStart;
2153
+ while (vwEnd < wordGroups.length - 1) {
2154
+ const grp = wordGroups[vwEnd];
2155
+ const lastText = grp[grp.length - 1].text;
2156
+ if (/\s$/.test(lastText))
2157
+ break;
2158
+ vwEnd += 1;
2159
+ }
2160
+ const combinedText = wordGroups
2161
+ .slice(vwStart, vwEnd + 1)
2162
+ .flatMap(g => g.map(s => s.text))
2163
+ .join('')
2164
+ .trim();
2165
+ const combinedStart = wordGroups[vwStart][0].timestamp;
2166
+ const lastGrp = wordGroups[vwEnd];
2167
+ const combinedEnd = lastGrp[lastGrp.length - 1].endtime;
2168
+ const combinedDuration = combinedEnd - combinedStart;
2169
+ const isCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(combinedText);
2170
+ const isRTL = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF]/.test(combinedText);
2171
+ const hasHyphen = combinedText.includes('-');
2172
+ const wordLen = combinedText.length;
2173
+ let isGrowableVW = !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 12;
2174
+ if (isGrowableVW) {
2175
+ if (wordLen < 3) {
2176
+ isGrowableVW =
2177
+ combinedDuration >= 1000 && combinedDuration >= wordLen * 500;
2178
+ }
2179
+ else {
2180
+ isGrowableVW =
2181
+ combinedDuration >= 800 && combinedDuration >= wordLen * 180;
2182
+ }
2183
+ }
2184
+ const isGlowingVW = isGrowableVW;
2185
+ let charOff = 0;
2186
+ for (let gi = vwStart; gi <= vwEnd; gi += 1) {
2187
+ groupGrowable[gi] = isGrowableVW;
2188
+ groupGlowing[gi] = isGlowingVW;
2189
+ vwFullText[gi] = combinedText;
2190
+ vwFullDuration[gi] = combinedDuration;
2191
+ vwCharOffset[gi] = charOff;
2192
+ vwStartMs[gi] = combinedStart;
2193
+ vwEndMs[gi] = combinedEnd;
2194
+ const grpText = wordGroups[gi].map(s => s.text).join('');
2195
+ charOff += grpText.replace(/\s/g, '').length;
2196
+ }
2197
+ vwStart = vwEnd + 1;
2198
+ }
2199
+ return {
2200
+ wordGroups,
2201
+ groupGrowable,
2202
+ groupGlowing,
2203
+ vwFullText,
2204
+ vwFullDuration,
2205
+ vwCharOffset,
2206
+ vwStartMs,
2207
+ vwEndMs,
2208
+ };
2209
+ });
2210
+ }
2016
2211
  _updateCharTimingData() {
2017
2212
  if (!this.shadowRoot)
2018
2213
  return;
2214
+ this._rebuildDomCache();
2019
2215
  // Get the computed font from the first syllable to ensure accuracy
2020
2216
  const referenceSyllable = this.shadowRoot.querySelector('.lyrics-syllable');
2021
2217
  if (!referenceSyllable)
@@ -2128,21 +2324,29 @@ let AmLyrics$1 = class AmLyrics extends i {
2128
2324
  this.scrollToActiveLineYouLy(lineElement, forceScroll, scrollDuration);
2129
2325
  }
2130
2326
  }
2327
+ setUserScrolling(value) {
2328
+ this.isUserScrolling = value;
2329
+ if (value) {
2330
+ this.lyricsContainer?.classList.add('user-scrolling');
2331
+ }
2332
+ else {
2333
+ this.lyricsContainer?.classList.remove('user-scrolling');
2334
+ }
2335
+ }
2131
2336
  handleUserScroll() {
2132
2337
  // Ignore programmatic scrolls and click-seek scrolls
2133
2338
  if (this.isProgrammaticScroll || this.isClickSeeking) {
2134
2339
  return;
2135
2340
  }
2136
2341
  // Mark that user is currently scrolling
2137
- this.isUserScrolling = true;
2138
- this.lyricsContainer?.classList.add('user-scrolling');
2342
+ this.setUserScrolling(true);
2139
2343
  // Clear any existing timeout
2140
2344
  if (this.userScrollTimeoutId) {
2141
2345
  clearTimeout(this.userScrollTimeoutId);
2142
2346
  }
2143
2347
  // Set timeout to re-enable auto-scroll after 2 seconds of no scrolling
2144
2348
  this.userScrollTimeoutId = window.setTimeout(() => {
2145
- this.isUserScrolling = false;
2349
+ this.setUserScrolling(false);
2146
2350
  this.userScrollTimeoutId = undefined;
2147
2351
  // Optionally scroll back to current active line when re-enabling auto-scroll
2148
2352
  if (this.activeLineIndices.length > 0) {
@@ -2151,25 +2355,23 @@ let AmLyrics$1 = class AmLyrics extends i {
2151
2355
  }, 2000);
2152
2356
  }
2153
2357
  findActiveLineIndices(time) {
2154
- if (!this.lyrics)
2358
+ if (!this.lyrics || this.lyrics.length === 0)
2155
2359
  return [];
2156
2360
  const activeLines = [];
2157
2361
  for (let i = 0; i < this.lyrics.length; i += 1) {
2158
2362
  const line = this.lyrics[i];
2159
2363
  let effectiveEndTime = line.endtime;
2160
- // Extend the "active" highlight window to abut the next line,
2161
- // leaving a 500ms gap for breathing/scrolling
2162
2364
  if (i < this.lyrics.length - 1) {
2163
2365
  const nextLineStart = this.lyrics[i + 1].timestamp;
2164
2366
  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
2367
  if (gapDuration < INSTRUMENTAL_THRESHOLD_MS) {
2168
2368
  if (effectiveEndTime < nextLineStart) {
2169
2369
  effectiveEndTime = Math.max(effectiveEndTime, nextLineStart - 500);
2170
2370
  }
2171
2371
  }
2172
2372
  }
2373
+ if (line.timestamp > time)
2374
+ break;
2173
2375
  if (time >= line.timestamp && time <= effectiveEndTime) {
2174
2376
  activeLines.push(i);
2175
2377
  }
@@ -2209,6 +2411,8 @@ let AmLyrics$1 = class AmLyrics extends i {
2209
2411
  * Used by the template to always render gap elements in the DOM.
2210
2412
  */
2211
2413
  findAllInstrumentalGaps() {
2414
+ if (this.cachedAllGaps.length > 0)
2415
+ return this.cachedAllGaps;
2212
2416
  if (!this.lyrics || this.lyrics.length === 0)
2213
2417
  return [];
2214
2418
  const gaps = [];
@@ -2227,6 +2431,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2227
2431
  gaps.push({ insertBeforeIndex: i + 1, gapStart, gapEnd });
2228
2432
  }
2229
2433
  }
2434
+ this.cachedAllGaps = gaps;
2230
2435
  return gaps;
2231
2436
  }
2232
2437
  startAnimationFromTime(time) {
@@ -2390,7 +2595,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2390
2595
  clearTimeout(this.userScrollTimeoutId);
2391
2596
  this.userScrollTimeoutId = undefined;
2392
2597
  }
2393
- this.isUserScrolling = false;
2598
+ this.setUserScrolling(false);
2394
2599
  // Reset active line tracking to prevent scroll fighting
2395
2600
  this.currentPrimaryActiveLine = null;
2396
2601
  this.lastPrimaryActiveLine = null;
@@ -2689,7 +2894,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2689
2894
  }
2690
2895
  this.lyricsContainer.classList.remove('not-focused', 'user-scrolling');
2691
2896
  this.isProgrammaticScroll = true;
2692
- this.isUserScrolling = false;
2897
+ this.setUserScrolling(false);
2693
2898
  if (this.userScrollTimeoutId) {
2694
2899
  clearTimeout(this.userScrollTimeoutId);
2695
2900
  this.userScrollTimeoutId = undefined;
@@ -3177,9 +3382,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3177
3382
  this.style.setProperty('--hover-background-color', this.hoverBackgroundColor);
3178
3383
  this.style.setProperty('--highlight-color', this.highlightColor);
3179
3384
  const sourceLabel = this.lyricsSource ?? 'Unavailable';
3180
- const isUnsynced = this.lyrics && this.lyrics.length > 0
3181
- ? this.lyrics.every(l => l.timestamp === 0 && l.endtime === 0)
3182
- : false;
3385
+ const isUnsynced = this.cachedIsUnsynced;
3183
3386
  const renderContent = () => {
3184
3387
  if (this.isLoading) {
3185
3388
  // Render stylized skeleton lines
@@ -3251,88 +3454,16 @@ let AmLyrics$1 = class AmLyrics extends i {
3251
3454
  // as the main vocal, so we intentionally do NOT render a separate
3252
3455
  // translation/romanization block for background — it would just duplicate
3253
3456
  // the main line's text.
3254
- // Group syllables by word: when part=true, append to previous word group
3255
- const wordGroups = [];
3256
- for (const syllable of line.text) {
3257
- if (syllable.part && wordGroups.length > 0) {
3258
- // Continuation of previous word
3259
- wordGroups[wordGroups.length - 1].push(syllable);
3260
- }
3261
- else {
3262
- // New word
3263
- wordGroups.push([syllable]);
3264
- }
3265
- }
3266
- // Pre-compute isGrowable per "visual word": adjacent groups whose text
3267
- // doesn't end with whitespace form one visual word (e.g. "a"+"live" = "alive").
3268
- // We evaluate growable on the combined text/duration, then propagate
3269
- // the result to each individual group so it renders through the
3270
- // single-syllable path (which supports char-level glow).
3271
- const groupGrowable = new Array(wordGroups.length).fill(false);
3272
- const groupGlowing = new Array(wordGroups.length).fill(false);
3273
- // Visual word info for growable char-level glow:
3274
- // Each group stores the combined visual word's text, duration, and
3275
- // the char offset of this group within the visual word.
3276
- const vwFullText = new Array(wordGroups.length).fill('');
3277
- const vwFullDuration = new Array(wordGroups.length).fill(0);
3278
- const vwCharOffset = new Array(wordGroups.length).fill(0);
3279
- const vwStartMs = new Array(wordGroups.length).fill(0);
3280
- const vwEndMs = new Array(wordGroups.length).fill(0);
3281
- {
3282
- let vwStart = 0;
3283
- while (vwStart < wordGroups.length) {
3284
- let vwEnd = vwStart;
3285
- while (vwEnd < wordGroups.length - 1) {
3286
- const grp = wordGroups[vwEnd];
3287
- const lastText = grp[grp.length - 1].text;
3288
- if (/\s$/.test(lastText))
3289
- break;
3290
- vwEnd += 1;
3291
- }
3292
- // Compute combined properties for this visual word
3293
- const combinedText = wordGroups
3294
- .slice(vwStart, vwEnd + 1)
3295
- .flatMap(g => g.map(s => s.text))
3296
- .join('')
3297
- .trim();
3298
- const combinedStart = wordGroups[vwStart][0].timestamp;
3299
- const lastGrp = wordGroups[vwEnd];
3300
- const combinedEnd = lastGrp[lastGrp.length - 1].endtime;
3301
- const combinedDuration = combinedEnd - combinedStart;
3302
- const isCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(combinedText);
3303
- const isRTL = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF]/.test(combinedText);
3304
- const hasHyphen = combinedText.includes('-');
3305
- const wordLen = combinedText.length;
3306
- let isGrowableVW = !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 12;
3307
- if (isGrowableVW) {
3308
- if (wordLen < 3) {
3309
- isGrowableVW =
3310
- combinedDuration >= 1110 && combinedDuration >= wordLen * 550;
3311
- }
3312
- else {
3313
- isGrowableVW =
3314
- combinedDuration >= 850 && combinedDuration >= wordLen * 200;
3315
- }
3316
- }
3317
- // Glow requirement (more strict)
3318
- const isGlowingVW = isGrowableVW &&
3319
- combinedDuration >= 1000 &&
3320
- combinedDuration >= combinedText.length * 250;
3321
- let charOff = 0;
3322
- for (let gi = vwStart; gi <= vwEnd; gi += 1) {
3323
- groupGrowable[gi] = isGrowableVW;
3324
- groupGlowing[gi] = isGlowingVW;
3325
- vwFullText[gi] = combinedText;
3326
- vwFullDuration[gi] = combinedDuration;
3327
- vwCharOffset[gi] = charOff;
3328
- vwStartMs[gi] = combinedStart;
3329
- vwEndMs[gi] = combinedEnd;
3330
- const grpText = wordGroups[gi].map(s => s.text).join('');
3331
- charOff += grpText.replace(/\s/g, '').length;
3332
- }
3333
- vwStart = vwEnd + 1;
3334
- }
3335
- }
3457
+ const bgPlacement = hasBackground
3458
+ ? AmLyrics.getBackgroundTextPlacement(line)
3459
+ : 'after';
3460
+ const lineData = this.cachedLineData?.[lineIndex];
3461
+ const wordGroups = lineData?.wordGroups ?? [];
3462
+ const groupGrowable = lineData?.groupGrowable ?? [];
3463
+ const groupGlowing = lineData?.groupGlowing ?? [];
3464
+ const vwFullText = lineData?.vwFullText ?? [];
3465
+ const vwFullDuration = lineData?.vwFullDuration ?? [];
3466
+ const vwCharOffset = lineData?.vwCharOffset ?? [];
3336
3467
  // Create main vocals using YouLyPlus syllable structure
3337
3468
  const mainVocalElement = b `<p class="main-vocal-container">
3338
3469
  ${wordGroups.map((group, groupIdx) => {
@@ -3555,7 +3686,9 @@ let AmLyrics$1 = class AmLyrics extends i {
3555
3686
  }}
3556
3687
  >
3557
3688
  <div class="lyrics-line-container">
3558
- ${mainVocalElement} ${backgroundVocalElement}
3689
+ ${bgPlacement === 'before' ? backgroundVocalElement : ''}
3690
+ ${mainVocalElement}
3691
+ ${bgPlacement === 'after' ? backgroundVocalElement : ''}
3559
3692
  ${translationElement} ${lineRomanizationElement}
3560
3693
  </div>
3561
3694
  </div>
@@ -3566,9 +3699,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3566
3699
  <div
3567
3700
  class="lyrics-container ${isUnsynced
3568
3701
  ? 'is-unsynced'
3569
- : 'blur-inactive-enabled'} ${this.isUserScrolling
3570
- ? 'user-scrolling'
3571
- : ''}"
3702
+ : 'blur-inactive-enabled'}"
3572
3703
  >
3573
3704
  ${!this.isLoading && this.lyrics && this.lyrics.length > 0
3574
3705
  ? b `
@@ -3683,17 +3814,15 @@ let AmLyrics$1 = class AmLyrics extends i {
3683
3814
  !this.hasFetchedAllProviders
3684
3815
  ? b `
3685
3816
  <button
3686
- class="download-button"
3817
+ class="download-button source-switch-btn"
3687
3818
  title="Switch Lyrics Source"
3688
3819
  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;"
3689
3820
  @click=${this.switchSource}
3690
3821
  ?disabled=${this.isFetchingAlternatives}
3691
3822
  >
3692
3823
  <svg
3693
- style="margin-right: 4px; ${this
3694
- .isFetchingAlternatives
3695
- ? 'animation: spin 1s linear infinite;'
3696
- : ''}"
3824
+ class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
3825
+ style="margin-right: 4px;"
3697
3826
  xmlns="http://www.w3.org/2000/svg"
3698
3827
  width="12"
3699
3828
  height="12"
@@ -3703,7 +3832,6 @@ let AmLyrics$1 = class AmLyrics extends i {
3703
3832
  stroke-width="2"
3704
3833
  stroke-linecap="round"
3705
3834
  stroke-linejoin="round"
3706
- class="lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
3707
3835
  >
3708
3836
  ${this.isFetchingAlternatives
3709
3837
  ? w `<path
@@ -3714,9 +3842,11 @@ let AmLyrics$1 = class AmLyrics extends i {
3714
3842
  ><path d="m21 8-4-4-4 4"></path
3715
3843
  ><path d="M17 4v16"></path>`}
3716
3844
  </svg>
3717
- ${this.isFetchingAlternatives
3845
+ <span class="source-switch-label"
3846
+ >${this.isFetchingAlternatives
3718
3847
  ? 'Switching...'
3719
- : 'Switch'}
3848
+ : 'Switch'}</span
3849
+ >
3720
3850
  </button>
3721
3851
  `
3722
3852
  : ''}
@@ -5001,18 +5131,9 @@ __decorate([
5001
5131
  __decorate([
5002
5132
  r()
5003
5133
  ], AmLyrics$1.prototype, "currentSourceIndex", void 0);
5004
- __decorate([
5005
- r()
5006
- ], AmLyrics$1.prototype, "isFetchingAlternatives", void 0);
5007
- __decorate([
5008
- r()
5009
- ], AmLyrics$1.prototype, "hasFetchedAllProviders", void 0);
5010
5134
  __decorate([
5011
5135
  e('.lyrics-container')
5012
5136
  ], AmLyrics$1.prototype, "lyricsContainer", void 0);
5013
- __decorate([
5014
- r()
5015
- ], AmLyrics$1.prototype, "isUserScrolling", void 0);
5016
5137
 
5017
5138
  // This creates the React-usable component
5018
5139
  const AmLyrics = createComponent({