@uimaxbai/am-lyrics 1.2.7 → 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/src/AmLyrics.ts CHANGED
@@ -2,7 +2,7 @@ import { css, html, LitElement, svg } from 'lit';
2
2
  import { property, query, state } from 'lit/decorators.js';
3
3
  import { GoogleService } from './GoogleService.js';
4
4
 
5
- const VERSION = '1.2.7';
5
+ const VERSION = '1.2.8';
6
6
  const INSTRUMENTAL_THRESHOLD_MS = 7000; // Show dots for gaps >= 7s
7
7
  const FETCH_TIMEOUT_MS = 8000; // Timeout for all lyrics fetch requests
8
8
  const SEEK_THRESHOLD_MS = 500;
@@ -1475,12 +1475,32 @@ export class AmLyrics extends LitElement {
1475
1475
  @state()
1476
1476
  private currentSourceIndex = 0;
1477
1477
 
1478
- @state()
1479
1478
  private isFetchingAlternatives = false;
1480
1479
 
1481
- @state()
1482
1480
  private hasFetchedAllProviders = false;
1483
1481
 
1482
+ private _updateFooter() {
1483
+ const footer = this.shadowRoot?.querySelector('.lyrics-footer');
1484
+ if (!footer) return;
1485
+ const switchBtn = footer.querySelector('.source-switch-btn');
1486
+ const svgEl = footer.querySelector('.source-switch-svg');
1487
+ const labelEl = footer.querySelector('.source-switch-label');
1488
+ if (switchBtn) {
1489
+ (switchBtn as HTMLButtonElement).disabled = this.isFetchingAlternatives;
1490
+ }
1491
+ if (svgEl) {
1492
+ svgEl.setAttribute(
1493
+ 'style',
1494
+ `margin-right: 4px; ${this.isFetchingAlternatives ? 'animation: spin 1s linear infinite;' : ''}`,
1495
+ );
1496
+ }
1497
+ if (labelEl) {
1498
+ labelEl.textContent = this.isFetchingAlternatives
1499
+ ? 'Switching...'
1500
+ : 'Switch';
1501
+ }
1502
+ }
1503
+
1484
1504
  private animationFrameId?: number;
1485
1505
 
1486
1506
  private mainWordAnimations: Map<
@@ -1500,7 +1520,6 @@ export class AmLyrics extends LitElement {
1500
1520
 
1501
1521
  private userScrollTimeoutId?: number;
1502
1522
 
1503
- @state()
1504
1523
  private isUserScrolling = false;
1505
1524
 
1506
1525
  private isProgrammaticScroll = false;
@@ -1512,6 +1531,33 @@ export class AmLyrics extends LitElement {
1512
1531
  // Cached DOM elements for animation updates
1513
1532
  private cachedLyricsLines: HTMLElement[] = [];
1514
1533
 
1534
+ // Cached line and gap element maps for fast lookup
1535
+ private lineElementCache = new Map<number, HTMLElement>();
1536
+
1537
+ private gapElementCache = new Map<number, HTMLElement>();
1538
+
1539
+ // Cached gap computation results
1540
+ private cachedAllGaps: Array<{
1541
+ insertBeforeIndex: number;
1542
+ gapStart: number;
1543
+ gapEnd: number;
1544
+ }> = [];
1545
+
1546
+ // Cached isUnsynced flag
1547
+ private cachedIsUnsynced = false;
1548
+
1549
+ // Cached pre-computed line data for render
1550
+ private cachedLineData: Array<{
1551
+ wordGroups: Syllable[][];
1552
+ groupGrowable: boolean[];
1553
+ groupGlowing: boolean[];
1554
+ vwFullText: string[];
1555
+ vwFullDuration: number[];
1556
+ vwCharOffset: number[];
1557
+ vwStartMs: number[];
1558
+ vwEndMs: number[];
1559
+ }> | null = null;
1560
+
1515
1561
  // Active line tracking
1516
1562
  private activeLineIds: Set<string> = new Set();
1517
1563
 
@@ -1602,6 +1648,7 @@ export class AmLyrics extends LitElement {
1602
1648
  this.currentSourceIndex = 0;
1603
1649
  this.isFetchingAlternatives = false;
1604
1650
  this.hasFetchedAllProviders = false;
1651
+ this._updateFooter();
1605
1652
  try {
1606
1653
  const resolvedMetadata = await this.resolveSongMetadata();
1607
1654
  // If a newer fetch was triggered while we awaited, bail out
@@ -1678,6 +1725,7 @@ export class AmLyrics extends LitElement {
1678
1725
  s.source === 'Tidal' ||
1679
1726
  s.source === 'Genius',
1680
1727
  );
1728
+ this._updateFooter();
1681
1729
 
1682
1730
  if (collectedSources.length > 0) {
1683
1731
  this.availableSources = AmLyrics.mergeAndSortSources(collectedSources);
@@ -1803,6 +1851,7 @@ export class AmLyrics extends LitElement {
1803
1851
 
1804
1852
  if (!this.hasFetchedAllProviders) {
1805
1853
  this.isFetchingAlternatives = true;
1854
+ this._updateFooter();
1806
1855
  try {
1807
1856
  const resolvedMetadata = await this.resolveSongMetadata();
1808
1857
  if (resolvedMetadata?.metadata) {
@@ -1865,6 +1914,7 @@ export class AmLyrics extends LitElement {
1865
1914
  } finally {
1866
1915
  this.hasFetchedAllProviders = true;
1867
1916
  this.isFetchingAlternatives = false;
1917
+ this._updateFooter();
1868
1918
  }
1869
1919
  }
1870
1920
 
@@ -3135,15 +3185,11 @@ export class AmLyrics extends LitElement {
3135
3185
  const linesChanged = !AmLyrics.arraysEqual(newActiveLines, oldActiveLines);
3136
3186
 
3137
3187
  if (linesChanged || isSeek) {
3138
- // Imperatively manage 'active' class so that scroll-animate and other
3139
- // imperative classes are never clobbered.
3140
3188
  if (this.lyricsContainer) {
3141
3189
  // Remove 'active' from lines that are no longer active
3142
3190
  for (const lineIndex of oldActiveLines) {
3143
3191
  if (!newActiveLines.includes(lineIndex)) {
3144
- const lineElement = this.lyricsContainer.querySelector(
3145
- `#lyrics-line-${lineIndex}`,
3146
- ) as HTMLElement;
3192
+ const lineElement = this._getLineElement(lineIndex);
3147
3193
  if (lineElement) {
3148
3194
  lineElement.classList.remove('active');
3149
3195
  AmLyrics.resetSyllables(lineElement);
@@ -3153,12 +3199,10 @@ export class AmLyrics extends LitElement {
3153
3199
  // Add 'active' to newly active lines
3154
3200
  for (const lineIndex of newActiveLines) {
3155
3201
  if (!oldActiveLines.includes(lineIndex)) {
3156
- const lineElement = this.lyricsContainer.querySelector(
3157
- `#lyrics-line-${lineIndex}`,
3158
- ) as HTMLElement;
3202
+ const lineElement = this._getLineElement(lineIndex);
3159
3203
  if (lineElement) {
3160
3204
  lineElement.classList.add('active');
3161
- lineElement.classList.remove('pre-active'); // Cleanup pre-active when fully active
3205
+ lineElement.classList.remove('pre-active');
3162
3206
  }
3163
3207
  }
3164
3208
  }
@@ -3170,17 +3214,13 @@ export class AmLyrics extends LitElement {
3170
3214
 
3171
3215
  this.startAnimationFromTime(newTime);
3172
3216
 
3173
- // Trigger scroll imperatively (was previously in updated() via @state)
3174
3217
  this._handleActiveLineScroll(oldActiveLines, isSeek);
3175
3218
  }
3176
3219
 
3177
- // YouLyPlus-style syllable animation updates
3178
3220
  if (this.lyricsContainer) {
3179
- // Update syllables in active lines
3221
+ // Update syllables in active lines using cached elements
3180
3222
  for (const lineIndex of this.activeLineIndices) {
3181
- const lineElement = this.lyricsContainer.querySelector(
3182
- `#lyrics-line-${lineIndex}`,
3183
- ) as HTMLElement;
3223
+ const lineElement = this._getLineElement(lineIndex);
3184
3224
  if (lineElement) {
3185
3225
  AmLyrics.updateSyllablesForLine(lineElement, newTime);
3186
3226
  }
@@ -3193,62 +3233,91 @@ export class AmLyrics extends LitElement {
3193
3233
  AmLyrics.updateSyllablesForLine(gapLine as HTMLElement, newTime);
3194
3234
  });
3195
3235
 
3196
- // Imperatively manage gap active state (template doesn't re-render on time changes)
3197
- const allGaps = this.lyricsContainer.querySelectorAll('.lyrics-gap');
3198
- allGaps.forEach(gap => {
3199
- const gapStartTime = parseFloat(
3200
- gap.getAttribute('data-start-time') || '0',
3201
- );
3202
- const gapEndTime = parseFloat(gap.getAttribute('data-end-time') || '0');
3203
- const shouldBeActive = newTime >= gapStartTime && newTime < gapEndTime;
3204
- const isActive = gap.classList.contains('active');
3205
- const isExiting = gap.classList.contains('gap-exiting');
3206
- // Start exit animation early so it completes before the next lyric
3207
- const exitLeadMs = GAP_EXIT_LEAD_MS;
3208
- const shouldStartExiting =
3209
- isActive && !isExiting && newTime >= gapEndTime - exitLeadMs;
3210
-
3211
- if (shouldBeActive && !isActive && !isExiting) {
3212
- // Entering gap: remove any leftover exit state, add active
3213
- gap.classList.remove('gap-exiting');
3214
- gap.classList.add('active');
3215
- // Mark dots whose time has already passed as finished, and
3216
- // trigger highlight on the dot currently in its time window
3217
- // so the first dot always lights up even on late load.
3218
- const dotSyllables = gap.querySelectorAll('.lyrics-syllable');
3219
- dotSyllables.forEach(dot => {
3220
- const dotStart = parseFloat(
3221
- dot.getAttribute('data-start-time') || '0',
3222
- );
3223
- const dotEnd = parseFloat(dot.getAttribute('data-end-time') || '0');
3224
- if (newTime > dotEnd) {
3225
- dot.classList.add('finished');
3226
- // Also ensure the highlight + animation fired so CSS state is correct
3227
- if (!dot.classList.contains('highlight')) {
3236
+ // Imperatively manage gap active state
3237
+ if (this.gapElementCache.size > 0) {
3238
+ for (const [, gap] of this.gapElementCache) {
3239
+ const gapStartTime = parseFloat(
3240
+ gap.getAttribute('data-start-time') || '0',
3241
+ );
3242
+ const gapEndTime = parseFloat(
3243
+ gap.getAttribute('data-end-time') || '0',
3244
+ );
3245
+ const shouldBeActive =
3246
+ newTime >= gapStartTime && newTime < gapEndTime;
3247
+ const isActive = gap.classList.contains('active');
3248
+ const isExiting = gap.classList.contains('gap-exiting');
3249
+ const exitLeadMs = GAP_EXIT_LEAD_MS;
3250
+ const shouldStartExiting =
3251
+ isActive && !isExiting && newTime >= gapEndTime - exitLeadMs;
3252
+
3253
+ if (shouldBeActive && !isActive && !isExiting) {
3254
+ gap.classList.remove('gap-exiting');
3255
+ gap.classList.add('active');
3256
+ const dotSyllables = gap.querySelectorAll('.lyrics-syllable');
3257
+ dotSyllables.forEach(dot => {
3258
+ const dotStart = parseFloat(
3259
+ dot.getAttribute('data-start-time') || '0',
3260
+ );
3261
+ const dotEnd = parseFloat(
3262
+ dot.getAttribute('data-end-time') || '0',
3263
+ );
3264
+ if (newTime > dotEnd) {
3265
+ dot.classList.add('finished');
3266
+ if (!dot.classList.contains('highlight')) {
3267
+ AmLyrics.updateSyllableAnimation(dot as HTMLElement);
3268
+ }
3269
+ } else if (newTime >= dotStart && newTime <= dotEnd) {
3228
3270
  AmLyrics.updateSyllableAnimation(dot as HTMLElement);
3229
3271
  }
3230
- } else if (newTime >= dotStart && newTime <= dotEnd) {
3231
- // Currently within this dot's window — trigger its highlight
3232
- AmLyrics.updateSyllableAnimation(dot as HTMLElement);
3233
- }
3234
- });
3235
- } else if (shouldStartExiting) {
3236
- // Exiting gap: keep visible while dots animate out
3237
- gap.classList.add('gap-exiting');
3238
- gap.classList.remove('active');
3239
- // After exit animation completes, remove gap-exiting to collapse
3240
- setTimeout(() => {
3272
+ });
3273
+ } else if (shouldStartExiting) {
3274
+ gap.classList.add('gap-exiting');
3275
+ gap.classList.remove('active');
3276
+ setTimeout(() => {
3277
+ gap.classList.remove('gap-exiting');
3278
+ }, GAP_EXIT_LEAD_MS);
3279
+ } else if (isActive && !shouldBeActive) {
3280
+ gap.classList.remove('active');
3241
3281
  gap.classList.remove('gap-exiting');
3242
- }, GAP_EXIT_LEAD_MS);
3243
- } else if (isActive && !shouldBeActive) {
3244
- // NEW: Immediate cleanup if we seeked out of valid range
3245
- gap.classList.remove('active');
3246
- gap.classList.remove('gap-exiting');
3247
- } else if (isExiting && newTime < gapEndTime - exitLeadMs) {
3248
- // NEW: Cleanup exiting state if we seeked backwards before exit window
3249
- gap.classList.remove('gap-exiting');
3282
+ } else if (isExiting && newTime < gapEndTime - exitLeadMs) {
3283
+ gap.classList.remove('gap-exiting');
3284
+ }
3250
3285
  }
3251
- });
3286
+ } else if (this.lyricsContainer) {
3287
+ // Fallback: no cache yet, use querySelectorAll
3288
+ const allGaps = this.lyricsContainer.querySelectorAll('.lyrics-gap');
3289
+ allGaps.forEach(gap => {
3290
+ const gapStartTime = parseFloat(
3291
+ gap.getAttribute('data-start-time') || '0',
3292
+ );
3293
+ const gapEndTime = parseFloat(
3294
+ gap.getAttribute('data-end-time') || '0',
3295
+ );
3296
+ const shouldBeActive =
3297
+ newTime >= gapStartTime && newTime < gapEndTime;
3298
+ const isActive = gap.classList.contains('active');
3299
+ const isExiting = gap.classList.contains('gap-exiting');
3300
+ const exitLeadMs = GAP_EXIT_LEAD_MS;
3301
+ const shouldStartExiting =
3302
+ isActive && !isExiting && newTime >= gapEndTime - exitLeadMs;
3303
+
3304
+ if (shouldBeActive && !isActive && !isExiting) {
3305
+ gap.classList.remove('gap-exiting');
3306
+ gap.classList.add('active');
3307
+ } else if (shouldStartExiting) {
3308
+ gap.classList.add('gap-exiting');
3309
+ gap.classList.remove('active');
3310
+ setTimeout(() => {
3311
+ gap.classList.remove('gap-exiting');
3312
+ }, GAP_EXIT_LEAD_MS);
3313
+ } else if (isActive && !shouldBeActive) {
3314
+ gap.classList.remove('active');
3315
+ gap.classList.remove('gap-exiting');
3316
+ } else if (isExiting && newTime < gapEndTime - exitLeadMs) {
3317
+ gap.classList.remove('gap-exiting');
3318
+ }
3319
+ });
3320
+ }
3252
3321
 
3253
3322
  // Track instrumental gap state
3254
3323
  const currentGap = this.findInstrumentalGapAt(newTime);
@@ -3271,9 +3340,7 @@ export class AmLyrics extends LitElement {
3271
3340
  const line = this.lyrics[i];
3272
3341
  const timeUntilStart = line.timestamp - newTime;
3273
3342
 
3274
- const nextLineEl = this.lyricsContainer.querySelector(
3275
- `#lyrics-line-${i}`,
3276
- ) as HTMLElement;
3343
+ const nextLineEl = this._getLineElement(i);
3277
3344
 
3278
3345
  const isBackToBack = this.activeLineIndices.length > 0;
3279
3346
  const leadTime = isBackToBack
@@ -3281,16 +3348,14 @@ export class AmLyrics extends LitElement {
3281
3348
  : PRE_SCROLL_LEAD_MS;
3282
3349
 
3283
3350
  if (timeUntilStart > leadTime) {
3284
- break; // Lines are ordered by timestamp, no need to check further
3351
+ break;
3285
3352
  }
3286
3353
 
3287
3354
  if (timeUntilStart > 0 && timeUntilStart <= leadTime) {
3288
- // Time to pre-scroll and pre-activate!
3289
3355
  if (nextLineEl) {
3290
3356
  preActiveLineIndex = i;
3291
3357
 
3292
3358
  if (!isBackToBack) {
3293
- // Apply unblur & zoom effect ahead of lyric start only if no line is currently active
3294
3359
  nextLineEl.classList.add('pre-active');
3295
3360
  }
3296
3361
  this.clearPreActiveClasses(i);
@@ -3316,6 +3381,9 @@ export class AmLyrics extends LitElement {
3316
3381
 
3317
3382
  updated(changedProperties: Map<string | number | symbol, unknown>) {
3318
3383
  if (changedProperties.has('lyrics')) {
3384
+ this._invalidateCaches();
3385
+ this._ensureLineDataCache();
3386
+ this._updateCachedIsUnsynced();
3319
3387
  // Recalculate timing data for accurate animations whenever lyrics change
3320
3388
  this._updateCharTimingData();
3321
3389
 
@@ -3325,9 +3393,7 @@ export class AmLyrics extends LitElement {
3325
3393
  if (this.lyricsContainer && this.lyrics) {
3326
3394
  const activeLines = this.findActiveLineIndices(this.currentTime);
3327
3395
  for (const lineIndex of activeLines) {
3328
- const lineEl = this.lyricsContainer.querySelector(
3329
- `#lyrics-line-${lineIndex}`,
3330
- ) as HTMLElement;
3396
+ const lineEl = this._getLineElement(lineIndex);
3331
3397
  if (lineEl) lineEl.classList.add('active');
3332
3398
  }
3333
3399
  }
@@ -3343,7 +3409,7 @@ export class AmLyrics extends LitElement {
3343
3409
  this.backgroundWordProgress.clear();
3344
3410
  this.mainWordAnimations.clear();
3345
3411
  this.backgroundWordAnimations.clear();
3346
- this.isUserScrolling = false;
3412
+ this.setUserScrolling(false);
3347
3413
 
3348
3414
  // Cancel any running animations
3349
3415
  if (this.animationFrameId) {
@@ -3402,9 +3468,7 @@ export class AmLyrics extends LitElement {
3402
3468
  );
3403
3469
  if (targetLineIndex === null) return;
3404
3470
 
3405
- const targetLine = this.lyricsContainer.querySelector(
3406
- `#lyrics-line-${targetLineIndex}`,
3407
- ) as HTMLElement;
3471
+ const targetLine = this._getLineElement(targetLineIndex);
3408
3472
 
3409
3473
  if (targetLine) {
3410
3474
  this.focusLine(targetLine, forceScroll);
@@ -3429,9 +3493,167 @@ export class AmLyrics extends LitElement {
3429
3493
  return 0;
3430
3494
  }
3431
3495
 
3496
+ private _rebuildDomCache() {
3497
+ if (!this.lyricsContainer) return;
3498
+
3499
+ this.lineElementCache.clear();
3500
+ this.gapElementCache.clear();
3501
+
3502
+ if (!this.lyrics) return;
3503
+
3504
+ for (let i = 0; i < this.lyrics.length; i += 1) {
3505
+ const lineEl = this.lyricsContainer.querySelector(
3506
+ `#lyrics-line-${i}`,
3507
+ ) as HTMLElement | null;
3508
+ if (lineEl) this.lineElementCache.set(i, lineEl);
3509
+
3510
+ const gapEl = this.lyricsContainer.querySelector(
3511
+ `#gap-${i}`,
3512
+ ) as HTMLElement | null;
3513
+ if (gapEl) this.gapElementCache.set(i, gapEl);
3514
+ }
3515
+ }
3516
+
3517
+ private _getLineElement(index: number): HTMLElement | null {
3518
+ const cached = this.lineElementCache.get(index);
3519
+ if (cached) return cached;
3520
+ if (!this.lyricsContainer) return null;
3521
+ const el = this.lyricsContainer.querySelector(
3522
+ `#lyrics-line-${index}`,
3523
+ ) as HTMLElement | null;
3524
+ if (el) this.lineElementCache.set(index, el);
3525
+ return el;
3526
+ }
3527
+
3528
+ private _getGapElement(index: number): HTMLElement | null {
3529
+ const cached = this.gapElementCache.get(index);
3530
+ if (cached) return cached;
3531
+ if (!this.lyricsContainer) return null;
3532
+ const el = this.lyricsContainer.querySelector(
3533
+ `#gap-${index}`,
3534
+ ) as HTMLElement | null;
3535
+ if (el) this.gapElementCache.set(index, el);
3536
+ return el;
3537
+ }
3538
+
3539
+ private _invalidateCaches() {
3540
+ this.cachedAllGaps = [];
3541
+ this.cachedIsUnsynced = false;
3542
+ this.cachedLineData = null;
3543
+ this.lineElementCache.clear();
3544
+ this.gapElementCache.clear();
3545
+ }
3546
+
3547
+ private _updateCachedIsUnsynced() {
3548
+ this.cachedIsUnsynced =
3549
+ this.lyrics && this.lyrics.length > 0
3550
+ ? this.lyrics.every(l => l.timestamp === 0 && l.endtime === 0)
3551
+ : false;
3552
+ }
3553
+
3554
+ private _ensureLineDataCache() {
3555
+ if (this.cachedLineData || !this.lyrics) return;
3556
+
3557
+ this.cachedLineData = this.lyrics.map(line => {
3558
+ const wordGroups: Syllable[][] = [];
3559
+ for (const syllable of line.text) {
3560
+ if (syllable.part && wordGroups.length > 0) {
3561
+ wordGroups[wordGroups.length - 1].push(syllable);
3562
+ } else {
3563
+ wordGroups.push([syllable]);
3564
+ }
3565
+ }
3566
+
3567
+ const groupGrowable: boolean[] = new Array(wordGroups.length).fill(false);
3568
+ const groupGlowing: boolean[] = new Array(wordGroups.length).fill(false);
3569
+ const vwFullText: string[] = new Array(wordGroups.length).fill('');
3570
+ const vwFullDuration: number[] = new Array(wordGroups.length).fill(0);
3571
+ const vwCharOffset: number[] = new Array(wordGroups.length).fill(0);
3572
+ const vwStartMs: number[] = new Array(wordGroups.length).fill(0);
3573
+ const vwEndMs: number[] = new Array(wordGroups.length).fill(0);
3574
+
3575
+ let vwStart = 0;
3576
+ while (vwStart < wordGroups.length) {
3577
+ let vwEnd = vwStart;
3578
+ while (vwEnd < wordGroups.length - 1) {
3579
+ const grp = wordGroups[vwEnd];
3580
+ const lastText = grp[grp.length - 1].text;
3581
+ if (/\s$/.test(lastText)) break;
3582
+ vwEnd += 1;
3583
+ }
3584
+
3585
+ const combinedText = wordGroups
3586
+ .slice(vwStart, vwEnd + 1)
3587
+ .flatMap(g => g.map(s => s.text))
3588
+ .join('')
3589
+ .trim();
3590
+ const combinedStart = wordGroups[vwStart][0].timestamp;
3591
+ const lastGrp = wordGroups[vwEnd];
3592
+ const combinedEnd = lastGrp[lastGrp.length - 1].endtime;
3593
+ const combinedDuration = combinedEnd - combinedStart;
3594
+
3595
+ const isCJK =
3596
+ /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(
3597
+ combinedText,
3598
+ );
3599
+ const isRTL =
3600
+ /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF]/.test(
3601
+ combinedText,
3602
+ );
3603
+ const hasHyphen = combinedText.includes('-');
3604
+
3605
+ const wordLen = combinedText.length;
3606
+ let isGrowableVW =
3607
+ !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 12;
3608
+ if (isGrowableVW) {
3609
+ if (wordLen < 3) {
3610
+ isGrowableVW =
3611
+ combinedDuration >= 1110 && combinedDuration >= wordLen * 550;
3612
+ } else {
3613
+ isGrowableVW =
3614
+ combinedDuration >= 850 && combinedDuration >= wordLen * 200;
3615
+ }
3616
+ }
3617
+
3618
+ const isGlowingVW =
3619
+ isGrowableVW &&
3620
+ combinedDuration >= 1000 &&
3621
+ combinedDuration >= combinedText.length * 250;
3622
+
3623
+ let charOff = 0;
3624
+ for (let gi = vwStart; gi <= vwEnd; gi += 1) {
3625
+ groupGrowable[gi] = isGrowableVW;
3626
+ groupGlowing[gi] = isGlowingVW;
3627
+ vwFullText[gi] = combinedText;
3628
+ vwFullDuration[gi] = combinedDuration;
3629
+ vwCharOffset[gi] = charOff;
3630
+ vwStartMs[gi] = combinedStart;
3631
+ vwEndMs[gi] = combinedEnd;
3632
+ const grpText = wordGroups[gi].map(s => s.text).join('');
3633
+ charOff += grpText.replace(/\s/g, '').length;
3634
+ }
3635
+
3636
+ vwStart = vwEnd + 1;
3637
+ }
3638
+
3639
+ return {
3640
+ wordGroups,
3641
+ groupGrowable,
3642
+ groupGlowing,
3643
+ vwFullText,
3644
+ vwFullDuration,
3645
+ vwCharOffset,
3646
+ vwStartMs,
3647
+ vwEndMs,
3648
+ };
3649
+ });
3650
+ }
3651
+
3432
3652
  private _updateCharTimingData() {
3433
3653
  if (!this.shadowRoot) return;
3434
3654
 
3655
+ this._rebuildDomCache();
3656
+
3435
3657
  // Get the computed font from the first syllable to ensure accuracy
3436
3658
  const referenceSyllable = this.shadowRoot.querySelector('.lyrics-syllable');
3437
3659
  if (!referenceSyllable) return;
@@ -3586,6 +3808,15 @@ export class AmLyrics extends LitElement {
3586
3808
  }
3587
3809
  }
3588
3810
 
3811
+ private setUserScrolling(value: boolean) {
3812
+ this.isUserScrolling = value;
3813
+ if (value) {
3814
+ this.lyricsContainer?.classList.add('user-scrolling');
3815
+ } else {
3816
+ this.lyricsContainer?.classList.remove('user-scrolling');
3817
+ }
3818
+ }
3819
+
3589
3820
  private handleUserScroll() {
3590
3821
  // Ignore programmatic scrolls and click-seek scrolls
3591
3822
  if (this.isProgrammaticScroll || this.isClickSeeking) {
@@ -3593,8 +3824,7 @@ export class AmLyrics extends LitElement {
3593
3824
  }
3594
3825
 
3595
3826
  // Mark that user is currently scrolling
3596
- this.isUserScrolling = true;
3597
- this.lyricsContainer?.classList.add('user-scrolling');
3827
+ this.setUserScrolling(true);
3598
3828
 
3599
3829
  // Clear any existing timeout
3600
3830
  if (this.userScrollTimeoutId) {
@@ -3603,7 +3833,7 @@ export class AmLyrics extends LitElement {
3603
3833
 
3604
3834
  // Set timeout to re-enable auto-scroll after 2 seconds of no scrolling
3605
3835
  this.userScrollTimeoutId = window.setTimeout(() => {
3606
- this.isUserScrolling = false;
3836
+ this.setUserScrolling(false);
3607
3837
  this.userScrollTimeoutId = undefined;
3608
3838
 
3609
3839
  // Optionally scroll back to current active line when re-enabling auto-scroll
@@ -3614,20 +3844,16 @@ export class AmLyrics extends LitElement {
3614
3844
  }
3615
3845
 
3616
3846
  private findActiveLineIndices(time: number): number[] {
3617
- if (!this.lyrics) return [];
3847
+ if (!this.lyrics || this.lyrics.length === 0) return [];
3618
3848
  const activeLines: number[] = [];
3849
+
3619
3850
  for (let i = 0; i < this.lyrics.length; i += 1) {
3620
3851
  const line = this.lyrics[i];
3621
3852
  let effectiveEndTime = line.endtime;
3622
3853
 
3623
- // Extend the "active" highlight window to abut the next line,
3624
- // leaving a 500ms gap for breathing/scrolling
3625
3854
  if (i < this.lyrics.length - 1) {
3626
3855
  const nextLineStart = this.lyrics[i + 1].timestamp;
3627
3856
  const gapDuration = nextLineStart - line.endtime;
3628
-
3629
- // If the gap is large enough to trigger the breathing dots,
3630
- // DO NOT extend the highlight. The text should dim when the dots appear.
3631
3857
  if (gapDuration < INSTRUMENTAL_THRESHOLD_MS) {
3632
3858
  if (effectiveEndTime < nextLineStart) {
3633
3859
  effectiveEndTime = Math.max(effectiveEndTime, nextLineStart - 500);
@@ -3635,6 +3861,7 @@ export class AmLyrics extends LitElement {
3635
3861
  }
3636
3862
  }
3637
3863
 
3864
+ if (line.timestamp > time) break;
3638
3865
  if (time >= line.timestamp && time <= effectiveEndTime) {
3639
3866
  activeLines.push(i);
3640
3867
  }
@@ -3684,6 +3911,7 @@ export class AmLyrics extends LitElement {
3684
3911
  gapStart: number;
3685
3912
  gapEnd: number;
3686
3913
  }> {
3914
+ if (this.cachedAllGaps.length > 0) return this.cachedAllGaps;
3687
3915
  if (!this.lyrics || this.lyrics.length === 0) return [];
3688
3916
  const gaps: Array<{
3689
3917
  insertBeforeIndex: number;
@@ -3708,6 +3936,7 @@ export class AmLyrics extends LitElement {
3708
3936
  }
3709
3937
  }
3710
3938
 
3939
+ this.cachedAllGaps = gaps;
3711
3940
  return gaps;
3712
3941
  }
3713
3942
 
@@ -3897,7 +4126,7 @@ export class AmLyrics extends LitElement {
3897
4126
  clearTimeout(this.userScrollTimeoutId);
3898
4127
  this.userScrollTimeoutId = undefined;
3899
4128
  }
3900
- this.isUserScrolling = false;
4129
+ this.setUserScrolling(false);
3901
4130
 
3902
4131
  // Reset active line tracking to prevent scroll fighting
3903
4132
  this.currentPrimaryActiveLine = null;
@@ -4288,7 +4517,7 @@ export class AmLyrics extends LitElement {
4288
4517
 
4289
4518
  this.lyricsContainer.classList.remove('not-focused', 'user-scrolling');
4290
4519
  this.isProgrammaticScroll = true;
4291
- this.isUserScrolling = false;
4520
+ this.setUserScrolling(false);
4292
4521
 
4293
4522
  if (this.userScrollTimeoutId) {
4294
4523
  clearTimeout(this.userScrollTimeoutId);
@@ -4888,10 +5117,7 @@ export class AmLyrics extends LitElement {
4888
5117
 
4889
5118
  const sourceLabel = this.lyricsSource ?? 'Unavailable';
4890
5119
 
4891
- const isUnsynced =
4892
- this.lyrics && this.lyrics.length > 0
4893
- ? this.lyrics.every(l => l.timestamp === 0 && l.endtime === 0)
4894
- : false;
5120
+ const isUnsynced = this.cachedIsUnsynced;
4895
5121
 
4896
5122
  const renderContent = () => {
4897
5123
  if (this.isLoading) {
@@ -4977,104 +5203,13 @@ export class AmLyrics extends LitElement {
4977
5203
  // translation/romanization block for background — it would just duplicate
4978
5204
  // the main line's text.
4979
5205
 
4980
- // Group syllables by word: when part=true, append to previous word group
4981
- const wordGroups: Syllable[][] = [];
4982
- for (const syllable of line.text) {
4983
- if (syllable.part && wordGroups.length > 0) {
4984
- // Continuation of previous word
4985
- wordGroups[wordGroups.length - 1].push(syllable);
4986
- } else {
4987
- // New word
4988
- wordGroups.push([syllable]);
4989
- }
4990
- }
4991
-
4992
- // Pre-compute isGrowable per "visual word": adjacent groups whose text
4993
- // doesn't end with whitespace form one visual word (e.g. "a"+"live" = "alive").
4994
- // We evaluate growable on the combined text/duration, then propagate
4995
- // the result to each individual group so it renders through the
4996
- // single-syllable path (which supports char-level glow).
4997
- const groupGrowable: boolean[] = new Array(wordGroups.length).fill(
4998
- false,
4999
- );
5000
- const groupGlowing: boolean[] = new Array(wordGroups.length).fill(
5001
- false,
5002
- );
5003
- // Visual word info for growable char-level glow:
5004
- // Each group stores the combined visual word's text, duration, and
5005
- // the char offset of this group within the visual word.
5006
- const vwFullText: string[] = new Array(wordGroups.length).fill('');
5007
- const vwFullDuration: number[] = new Array(wordGroups.length).fill(0);
5008
- const vwCharOffset: number[] = new Array(wordGroups.length).fill(0);
5009
- const vwStartMs: number[] = new Array(wordGroups.length).fill(0);
5010
- const vwEndMs: number[] = new Array(wordGroups.length).fill(0);
5011
- {
5012
- let vwStart = 0;
5013
- while (vwStart < wordGroups.length) {
5014
- let vwEnd = vwStart;
5015
- while (vwEnd < wordGroups.length - 1) {
5016
- const grp = wordGroups[vwEnd];
5017
- const lastText = grp[grp.length - 1].text;
5018
- if (/\s$/.test(lastText)) break;
5019
- vwEnd += 1;
5020
- }
5021
-
5022
- // Compute combined properties for this visual word
5023
- const combinedText = wordGroups
5024
- .slice(vwStart, vwEnd + 1)
5025
- .flatMap(g => g.map(s => s.text))
5026
- .join('')
5027
- .trim();
5028
- const combinedStart = wordGroups[vwStart][0].timestamp;
5029
- const lastGrp = wordGroups[vwEnd];
5030
- const combinedEnd = lastGrp[lastGrp.length - 1].endtime;
5031
- const combinedDuration = combinedEnd - combinedStart;
5032
-
5033
- const isCJK =
5034
- /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(
5035
- combinedText,
5036
- );
5037
- const isRTL =
5038
- /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF]/.test(
5039
- combinedText,
5040
- );
5041
- const hasHyphen = combinedText.includes('-');
5042
-
5043
- const wordLen = combinedText.length;
5044
- let isGrowableVW =
5045
- !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 12;
5046
- if (isGrowableVW) {
5047
- if (wordLen < 3) {
5048
- isGrowableVW =
5049
- combinedDuration >= 1110 && combinedDuration >= wordLen * 550;
5050
- } else {
5051
- isGrowableVW =
5052
- combinedDuration >= 850 && combinedDuration >= wordLen * 200;
5053
- }
5054
- }
5055
-
5056
- // Glow requirement (more strict)
5057
- const isGlowingVW =
5058
- isGrowableVW &&
5059
- combinedDuration >= 1000 &&
5060
- combinedDuration >= combinedText.length * 250;
5061
-
5062
- let charOff = 0;
5063
- for (let gi = vwStart; gi <= vwEnd; gi += 1) {
5064
- groupGrowable[gi] = isGrowableVW;
5065
- groupGlowing[gi] = isGlowingVW;
5066
- vwFullText[gi] = combinedText;
5067
- vwFullDuration[gi] = combinedDuration;
5068
- vwCharOffset[gi] = charOff;
5069
- vwStartMs[gi] = combinedStart;
5070
- vwEndMs[gi] = combinedEnd;
5071
- const grpText = wordGroups[gi].map(s => s.text).join('');
5072
- charOff += grpText.replace(/\s/g, '').length;
5073
- }
5074
-
5075
- vwStart = vwEnd + 1;
5076
- }
5077
- }
5206
+ const lineData = this.cachedLineData?.[lineIndex];
5207
+ const wordGroups = lineData?.wordGroups ?? [];
5208
+ const groupGrowable = lineData?.groupGrowable ?? [];
5209
+ const groupGlowing = lineData?.groupGlowing ?? [];
5210
+ const vwFullText = lineData?.vwFullText ?? [];
5211
+ const vwFullDuration = lineData?.vwFullDuration ?? [];
5212
+ const vwCharOffset = lineData?.vwCharOffset ?? [];
5078
5213
 
5079
5214
  // Create main vocals using YouLyPlus syllable structure
5080
5215
  const mainVocalElement = html`<p class="main-vocal-container">
@@ -5350,9 +5485,7 @@ export class AmLyrics extends LitElement {
5350
5485
  <div
5351
5486
  class="lyrics-container ${isUnsynced
5352
5487
  ? 'is-unsynced'
5353
- : 'blur-inactive-enabled'} ${this.isUserScrolling
5354
- ? 'user-scrolling'
5355
- : ''}"
5488
+ : 'blur-inactive-enabled'}"
5356
5489
  >
5357
5490
  ${!this.isLoading && this.lyrics && this.lyrics.length > 0
5358
5491
  ? html`
@@ -5467,17 +5600,15 @@ export class AmLyrics extends LitElement {
5467
5600
  !this.hasFetchedAllProviders
5468
5601
  ? html`
5469
5602
  <button
5470
- class="download-button"
5603
+ class="download-button source-switch-btn"
5471
5604
  title="Switch Lyrics Source"
5472
5605
  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;"
5473
5606
  @click=${this.switchSource}
5474
5607
  ?disabled=${this.isFetchingAlternatives}
5475
5608
  >
5476
5609
  <svg
5477
- style="margin-right: 4px; ${this
5478
- .isFetchingAlternatives
5479
- ? 'animation: spin 1s linear infinite;'
5480
- : ''}"
5610
+ class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
5611
+ style="margin-right: 4px;"
5481
5612
  xmlns="http://www.w3.org/2000/svg"
5482
5613
  width="12"
5483
5614
  height="12"
@@ -5487,7 +5618,6 @@ export class AmLyrics extends LitElement {
5487
5618
  stroke-width="2"
5488
5619
  stroke-linecap="round"
5489
5620
  stroke-linejoin="round"
5490
- class="lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
5491
5621
  >
5492
5622
  ${this.isFetchingAlternatives
5493
5623
  ? svg`<path
@@ -5498,9 +5628,11 @@ export class AmLyrics extends LitElement {
5498
5628
  ><path d="m21 8-4-4-4 4"></path
5499
5629
  ><path d="M17 4v16"></path>`}
5500
5630
  </svg>
5501
- ${this.isFetchingAlternatives
5502
- ? 'Switching...'
5503
- : 'Switch'}
5631
+ <span class="source-switch-label"
5632
+ >${this.isFetchingAlternatives
5633
+ ? 'Switching...'
5634
+ : 'Switch'}</span
5635
+ >
5504
5636
  </button>
5505
5637
  `
5506
5638
  : ''}