@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/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.9';
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');
3281
+ gap.classList.remove('gap-exiting');
3282
+ } else if (isExiting && newTime < gapEndTime - exitLeadMs) {
3241
3283
  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');
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,13 +3468,32 @@ 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);
3472
+ if (!targetLine) return;
3408
3473
 
3409
- if (targetLine) {
3410
- this.focusLine(targetLine, forceScroll);
3474
+ // Only scroll snappily when lines are essentially back-to-back.
3475
+ // If there is any noticeable gap between them, scroll slower.
3476
+ let scrollDuration: number | undefined;
3477
+ const prevPrimaryIndex = AmLyrics.getLineIndexFromElement(
3478
+ this.currentPrimaryActiveLine,
3479
+ );
3480
+ if (
3481
+ prevPrimaryIndex !== null &&
3482
+ targetLineIndex > prevPrimaryIndex &&
3483
+ this.lyrics
3484
+ ) {
3485
+ const gap =
3486
+ this.lyrics[targetLineIndex].timestamp -
3487
+ this.lyrics[prevPrimaryIndex].endtime;
3488
+ if (gap > 200) {
3489
+ scrollDuration = Math.min(
3490
+ Math.max(gap * 0.6, SCROLL_ANIMATION_DURATION_MS),
3491
+ 2000,
3492
+ );
3493
+ }
3411
3494
  }
3495
+
3496
+ this.focusLine(targetLine, forceScroll, scrollDuration);
3412
3497
  }
3413
3498
 
3414
3499
  private _textWidthCanvas: HTMLCanvasElement | undefined;
@@ -3429,9 +3514,164 @@ export class AmLyrics extends LitElement {
3429
3514
  return 0;
3430
3515
  }
3431
3516
 
3517
+ private _rebuildDomCache() {
3518
+ if (!this.lyricsContainer) return;
3519
+
3520
+ this.lineElementCache.clear();
3521
+ this.gapElementCache.clear();
3522
+
3523
+ if (!this.lyrics) return;
3524
+
3525
+ for (let i = 0; i < this.lyrics.length; i += 1) {
3526
+ const lineEl = this.lyricsContainer.querySelector(
3527
+ `#lyrics-line-${i}`,
3528
+ ) as HTMLElement | null;
3529
+ if (lineEl) this.lineElementCache.set(i, lineEl);
3530
+
3531
+ const gapEl = this.lyricsContainer.querySelector(
3532
+ `#gap-${i}`,
3533
+ ) as HTMLElement | null;
3534
+ if (gapEl) this.gapElementCache.set(i, gapEl);
3535
+ }
3536
+ }
3537
+
3538
+ private _getLineElement(index: number): HTMLElement | null {
3539
+ const cached = this.lineElementCache.get(index);
3540
+ if (cached) return cached;
3541
+ if (!this.lyricsContainer) return null;
3542
+ const el = this.lyricsContainer.querySelector(
3543
+ `#lyrics-line-${index}`,
3544
+ ) as HTMLElement | null;
3545
+ if (el) this.lineElementCache.set(index, el);
3546
+ return el;
3547
+ }
3548
+
3549
+ private _getGapElement(index: number): HTMLElement | null {
3550
+ const cached = this.gapElementCache.get(index);
3551
+ if (cached) return cached;
3552
+ if (!this.lyricsContainer) return null;
3553
+ const el = this.lyricsContainer.querySelector(
3554
+ `#gap-${index}`,
3555
+ ) as HTMLElement | null;
3556
+ if (el) this.gapElementCache.set(index, el);
3557
+ return el;
3558
+ }
3559
+
3560
+ private _invalidateCaches() {
3561
+ this.cachedAllGaps = [];
3562
+ this.cachedIsUnsynced = false;
3563
+ this.cachedLineData = null;
3564
+ this.lineElementCache.clear();
3565
+ this.gapElementCache.clear();
3566
+ }
3567
+
3568
+ private _updateCachedIsUnsynced() {
3569
+ this.cachedIsUnsynced =
3570
+ this.lyrics && this.lyrics.length > 0
3571
+ ? this.lyrics.every(l => l.timestamp === 0 && l.endtime === 0)
3572
+ : false;
3573
+ }
3574
+
3575
+ private _ensureLineDataCache() {
3576
+ if (this.cachedLineData || !this.lyrics) return;
3577
+
3578
+ this.cachedLineData = this.lyrics.map(line => {
3579
+ const wordGroups: Syllable[][] = [];
3580
+ for (const syllable of line.text) {
3581
+ if (syllable.part && wordGroups.length > 0) {
3582
+ wordGroups[wordGroups.length - 1].push(syllable);
3583
+ } else {
3584
+ wordGroups.push([syllable]);
3585
+ }
3586
+ }
3587
+
3588
+ const groupGrowable: boolean[] = new Array(wordGroups.length).fill(false);
3589
+ const groupGlowing: boolean[] = new Array(wordGroups.length).fill(false);
3590
+ const vwFullText: string[] = new Array(wordGroups.length).fill('');
3591
+ const vwFullDuration: number[] = new Array(wordGroups.length).fill(0);
3592
+ const vwCharOffset: number[] = new Array(wordGroups.length).fill(0);
3593
+ const vwStartMs: number[] = new Array(wordGroups.length).fill(0);
3594
+ const vwEndMs: number[] = new Array(wordGroups.length).fill(0);
3595
+
3596
+ let vwStart = 0;
3597
+ while (vwStart < wordGroups.length) {
3598
+ let vwEnd = vwStart;
3599
+ while (vwEnd < wordGroups.length - 1) {
3600
+ const grp = wordGroups[vwEnd];
3601
+ const lastText = grp[grp.length - 1].text;
3602
+ if (/\s$/.test(lastText)) break;
3603
+ vwEnd += 1;
3604
+ }
3605
+
3606
+ const combinedText = wordGroups
3607
+ .slice(vwStart, vwEnd + 1)
3608
+ .flatMap(g => g.map(s => s.text))
3609
+ .join('')
3610
+ .trim();
3611
+ const combinedStart = wordGroups[vwStart][0].timestamp;
3612
+ const lastGrp = wordGroups[vwEnd];
3613
+ const combinedEnd = lastGrp[lastGrp.length - 1].endtime;
3614
+ const combinedDuration = combinedEnd - combinedStart;
3615
+
3616
+ const isCJK =
3617
+ /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(
3618
+ combinedText,
3619
+ );
3620
+ const isRTL =
3621
+ /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF]/.test(
3622
+ combinedText,
3623
+ );
3624
+ const hasHyphen = combinedText.includes('-');
3625
+
3626
+ const wordLen = combinedText.length;
3627
+ let isGrowableVW =
3628
+ !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 12;
3629
+ if (isGrowableVW) {
3630
+ if (wordLen < 3) {
3631
+ isGrowableVW =
3632
+ combinedDuration >= 1000 && combinedDuration >= wordLen * 500;
3633
+ } else {
3634
+ isGrowableVW =
3635
+ combinedDuration >= 800 && combinedDuration >= wordLen * 180;
3636
+ }
3637
+ }
3638
+
3639
+ const isGlowingVW = isGrowableVW;
3640
+
3641
+ let charOff = 0;
3642
+ for (let gi = vwStart; gi <= vwEnd; gi += 1) {
3643
+ groupGrowable[gi] = isGrowableVW;
3644
+ groupGlowing[gi] = isGlowingVW;
3645
+ vwFullText[gi] = combinedText;
3646
+ vwFullDuration[gi] = combinedDuration;
3647
+ vwCharOffset[gi] = charOff;
3648
+ vwStartMs[gi] = combinedStart;
3649
+ vwEndMs[gi] = combinedEnd;
3650
+ const grpText = wordGroups[gi].map(s => s.text).join('');
3651
+ charOff += grpText.replace(/\s/g, '').length;
3652
+ }
3653
+
3654
+ vwStart = vwEnd + 1;
3655
+ }
3656
+
3657
+ return {
3658
+ wordGroups,
3659
+ groupGrowable,
3660
+ groupGlowing,
3661
+ vwFullText,
3662
+ vwFullDuration,
3663
+ vwCharOffset,
3664
+ vwStartMs,
3665
+ vwEndMs,
3666
+ };
3667
+ });
3668
+ }
3669
+
3432
3670
  private _updateCharTimingData() {
3433
3671
  if (!this.shadowRoot) return;
3434
3672
 
3673
+ this._rebuildDomCache();
3674
+
3435
3675
  // Get the computed font from the first syllable to ensure accuracy
3436
3676
  const referenceSyllable = this.shadowRoot.querySelector('.lyrics-syllable');
3437
3677
  if (!referenceSyllable) return;
@@ -3586,6 +3826,15 @@ export class AmLyrics extends LitElement {
3586
3826
  }
3587
3827
  }
3588
3828
 
3829
+ private setUserScrolling(value: boolean) {
3830
+ this.isUserScrolling = value;
3831
+ if (value) {
3832
+ this.lyricsContainer?.classList.add('user-scrolling');
3833
+ } else {
3834
+ this.lyricsContainer?.classList.remove('user-scrolling');
3835
+ }
3836
+ }
3837
+
3589
3838
  private handleUserScroll() {
3590
3839
  // Ignore programmatic scrolls and click-seek scrolls
3591
3840
  if (this.isProgrammaticScroll || this.isClickSeeking) {
@@ -3593,8 +3842,7 @@ export class AmLyrics extends LitElement {
3593
3842
  }
3594
3843
 
3595
3844
  // Mark that user is currently scrolling
3596
- this.isUserScrolling = true;
3597
- this.lyricsContainer?.classList.add('user-scrolling');
3845
+ this.setUserScrolling(true);
3598
3846
 
3599
3847
  // Clear any existing timeout
3600
3848
  if (this.userScrollTimeoutId) {
@@ -3603,7 +3851,7 @@ export class AmLyrics extends LitElement {
3603
3851
 
3604
3852
  // Set timeout to re-enable auto-scroll after 2 seconds of no scrolling
3605
3853
  this.userScrollTimeoutId = window.setTimeout(() => {
3606
- this.isUserScrolling = false;
3854
+ this.setUserScrolling(false);
3607
3855
  this.userScrollTimeoutId = undefined;
3608
3856
 
3609
3857
  // Optionally scroll back to current active line when re-enabling auto-scroll
@@ -3614,20 +3862,16 @@ export class AmLyrics extends LitElement {
3614
3862
  }
3615
3863
 
3616
3864
  private findActiveLineIndices(time: number): number[] {
3617
- if (!this.lyrics) return [];
3865
+ if (!this.lyrics || this.lyrics.length === 0) return [];
3618
3866
  const activeLines: number[] = [];
3867
+
3619
3868
  for (let i = 0; i < this.lyrics.length; i += 1) {
3620
3869
  const line = this.lyrics[i];
3621
3870
  let effectiveEndTime = line.endtime;
3622
3871
 
3623
- // Extend the "active" highlight window to abut the next line,
3624
- // leaving a 500ms gap for breathing/scrolling
3625
3872
  if (i < this.lyrics.length - 1) {
3626
3873
  const nextLineStart = this.lyrics[i + 1].timestamp;
3627
3874
  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
3875
  if (gapDuration < INSTRUMENTAL_THRESHOLD_MS) {
3632
3876
  if (effectiveEndTime < nextLineStart) {
3633
3877
  effectiveEndTime = Math.max(effectiveEndTime, nextLineStart - 500);
@@ -3635,6 +3879,7 @@ export class AmLyrics extends LitElement {
3635
3879
  }
3636
3880
  }
3637
3881
 
3882
+ if (line.timestamp > time) break;
3638
3883
  if (time >= line.timestamp && time <= effectiveEndTime) {
3639
3884
  activeLines.push(i);
3640
3885
  }
@@ -3684,6 +3929,7 @@ export class AmLyrics extends LitElement {
3684
3929
  gapStart: number;
3685
3930
  gapEnd: number;
3686
3931
  }> {
3932
+ if (this.cachedAllGaps.length > 0) return this.cachedAllGaps;
3687
3933
  if (!this.lyrics || this.lyrics.length === 0) return [];
3688
3934
  const gaps: Array<{
3689
3935
  insertBeforeIndex: number;
@@ -3708,6 +3954,7 @@ export class AmLyrics extends LitElement {
3708
3954
  }
3709
3955
  }
3710
3956
 
3957
+ this.cachedAllGaps = gaps;
3711
3958
  return gaps;
3712
3959
  }
3713
3960
 
@@ -3897,7 +4144,7 @@ export class AmLyrics extends LitElement {
3897
4144
  clearTimeout(this.userScrollTimeoutId);
3898
4145
  this.userScrollTimeoutId = undefined;
3899
4146
  }
3900
- this.isUserScrolling = false;
4147
+ this.setUserScrolling(false);
3901
4148
 
3902
4149
  // Reset active line tracking to prevent scroll fighting
3903
4150
  this.currentPrimaryActiveLine = null;
@@ -4288,7 +4535,7 @@ export class AmLyrics extends LitElement {
4288
4535
 
4289
4536
  this.lyricsContainer.classList.remove('not-focused', 'user-scrolling');
4290
4537
  this.isProgrammaticScroll = true;
4291
- this.isUserScrolling = false;
4538
+ this.setUserScrolling(false);
4292
4539
 
4293
4540
  if (this.userScrollTimeoutId) {
4294
4541
  clearTimeout(this.userScrollTimeoutId);
@@ -4888,10 +5135,7 @@ export class AmLyrics extends LitElement {
4888
5135
 
4889
5136
  const sourceLabel = this.lyricsSource ?? 'Unavailable';
4890
5137
 
4891
- const isUnsynced =
4892
- this.lyrics && this.lyrics.length > 0
4893
- ? this.lyrics.every(l => l.timestamp === 0 && l.endtime === 0)
4894
- : false;
5138
+ const isUnsynced = this.cachedIsUnsynced;
4895
5139
 
4896
5140
  const renderContent = () => {
4897
5141
  if (this.isLoading) {
@@ -4977,104 +5221,17 @@ export class AmLyrics extends LitElement {
4977
5221
  // translation/romanization block for background — it would just duplicate
4978
5222
  // the main line's text.
4979
5223
 
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
- }
5224
+ const bgPlacement = hasBackground
5225
+ ? AmLyrics.getBackgroundTextPlacement(line)
5226
+ : 'after';
4991
5227
 
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
- }
5228
+ const lineData = this.cachedLineData?.[lineIndex];
5229
+ const wordGroups = lineData?.wordGroups ?? [];
5230
+ const groupGrowable = lineData?.groupGrowable ?? [];
5231
+ const groupGlowing = lineData?.groupGlowing ?? [];
5232
+ const vwFullText = lineData?.vwFullText ?? [];
5233
+ const vwFullDuration = lineData?.vwFullDuration ?? [];
5234
+ const vwCharOffset = lineData?.vwCharOffset ?? [];
5078
5235
 
5079
5236
  // Create main vocals using YouLyPlus syllable structure
5080
5237
  const mainVocalElement = html`<p class="main-vocal-container">
@@ -5338,7 +5495,9 @@ export class AmLyrics extends LitElement {
5338
5495
  }}
5339
5496
  >
5340
5497
  <div class="lyrics-line-container">
5341
- ${mainVocalElement} ${backgroundVocalElement}
5498
+ ${bgPlacement === 'before' ? backgroundVocalElement : ''}
5499
+ ${mainVocalElement}
5500
+ ${bgPlacement === 'after' ? backgroundVocalElement : ''}
5342
5501
  ${translationElement} ${lineRomanizationElement}
5343
5502
  </div>
5344
5503
  </div>
@@ -5350,9 +5509,7 @@ export class AmLyrics extends LitElement {
5350
5509
  <div
5351
5510
  class="lyrics-container ${isUnsynced
5352
5511
  ? 'is-unsynced'
5353
- : 'blur-inactive-enabled'} ${this.isUserScrolling
5354
- ? 'user-scrolling'
5355
- : ''}"
5512
+ : 'blur-inactive-enabled'}"
5356
5513
  >
5357
5514
  ${!this.isLoading && this.lyrics && this.lyrics.length > 0
5358
5515
  ? html`
@@ -5467,17 +5624,15 @@ export class AmLyrics extends LitElement {
5467
5624
  !this.hasFetchedAllProviders
5468
5625
  ? html`
5469
5626
  <button
5470
- class="download-button"
5627
+ class="download-button source-switch-btn"
5471
5628
  title="Switch Lyrics Source"
5472
5629
  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
5630
  @click=${this.switchSource}
5474
5631
  ?disabled=${this.isFetchingAlternatives}
5475
5632
  >
5476
5633
  <svg
5477
- style="margin-right: 4px; ${this
5478
- .isFetchingAlternatives
5479
- ? 'animation: spin 1s linear infinite;'
5480
- : ''}"
5634
+ class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
5635
+ style="margin-right: 4px;"
5481
5636
  xmlns="http://www.w3.org/2000/svg"
5482
5637
  width="12"
5483
5638
  height="12"
@@ -5487,7 +5642,6 @@ export class AmLyrics extends LitElement {
5487
5642
  stroke-width="2"
5488
5643
  stroke-linecap="round"
5489
5644
  stroke-linejoin="round"
5490
- class="lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
5491
5645
  >
5492
5646
  ${this.isFetchingAlternatives
5493
5647
  ? svg`<path
@@ -5498,9 +5652,11 @@ export class AmLyrics extends LitElement {
5498
5652
  ><path d="m21 8-4-4-4 4"></path
5499
5653
  ><path d="M17 4v16"></path>`}
5500
5654
  </svg>
5501
- ${this.isFetchingAlternatives
5502
- ? 'Switching...'
5503
- : 'Switch'}
5655
+ <span class="source-switch-label"
5656
+ >${this.isFetchingAlternatives
5657
+ ? 'Switching...'
5658
+ : 'Switch'}</span
5659
+ >
5504
5660
  </button>
5505
5661
  `
5506
5662
  : ''}