@uimaxbai/am-lyrics 1.2.6 → 1.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/AmLyrics.ts CHANGED
@@ -2,17 +2,17 @@ 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.6';
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;
9
9
  const PRE_SCROLL_LEAD_MS = 500;
10
- const PRE_SCROLL_LEAD_SHORT_MS = 150;
10
+ const PRE_SCROLL_LEAD_SHORT_MS = 350;
11
11
  const SCROLL_ANIMATION_DURATION_MS = 280;
12
12
  const SCROLL_DELAY_INCREMENT_MS = 24;
13
13
  const GAP_PULSE_DURATION_MS = 4000;
14
14
  const GAP_PULSE_CYCLE_MS = GAP_PULSE_DURATION_MS * 2;
15
- const GAP_EXIT_LEAD_MS = 360;
15
+ const GAP_EXIT_LEAD_MS = 600;
16
16
  const GAP_MIN_SCALE = 0.85;
17
17
 
18
18
  /**
@@ -1010,8 +1010,8 @@ export class AmLyrics extends LitElement {
1010
1010
  0.75em 100%,
1011
1011
  0% 100%;
1012
1012
  background-position:
1013
- -0.375em 0%,
1014
- left;
1013
+ -0.75em 0%,
1014
+ -0.375em 0%;
1015
1015
  }
1016
1016
  100% {
1017
1017
  background-size:
@@ -1105,7 +1105,7 @@ export class AmLyrics extends LitElement {
1105
1105
  0.75em 100%,
1106
1106
  0% 100%;
1107
1107
  background-position:
1108
- -0.85em 0%,
1108
+ -0.75em 0%,
1109
1109
  left;
1110
1110
  }
1111
1111
  to {
@@ -1113,7 +1113,7 @@ export class AmLyrics extends LitElement {
1113
1113
  0.75em 100%,
1114
1114
  0% 100%;
1115
1115
  background-position:
1116
- -0.85em 0%,
1116
+ -0.375em 0%,
1117
1117
  left;
1118
1118
  }
1119
1119
  }
@@ -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);
@@ -3302,8 +3367,7 @@ export class AmLyrics extends LitElement {
3302
3367
  this.focusLine(
3303
3368
  nextLineEl,
3304
3369
  false,
3305
- slowScrollDuration,
3306
- !!currentGap,
3370
+ isBackToBack ? 500 : slowScrollDuration,
3307
3371
  );
3308
3372
  }
3309
3373
  break;
@@ -3317,6 +3381,9 @@ export class AmLyrics extends LitElement {
3317
3381
 
3318
3382
  updated(changedProperties: Map<string | number | symbol, unknown>) {
3319
3383
  if (changedProperties.has('lyrics')) {
3384
+ this._invalidateCaches();
3385
+ this._ensureLineDataCache();
3386
+ this._updateCachedIsUnsynced();
3320
3387
  // Recalculate timing data for accurate animations whenever lyrics change
3321
3388
  this._updateCharTimingData();
3322
3389
 
@@ -3326,9 +3393,7 @@ export class AmLyrics extends LitElement {
3326
3393
  if (this.lyricsContainer && this.lyrics) {
3327
3394
  const activeLines = this.findActiveLineIndices(this.currentTime);
3328
3395
  for (const lineIndex of activeLines) {
3329
- const lineEl = this.lyricsContainer.querySelector(
3330
- `#lyrics-line-${lineIndex}`,
3331
- ) as HTMLElement;
3396
+ const lineEl = this._getLineElement(lineIndex);
3332
3397
  if (lineEl) lineEl.classList.add('active');
3333
3398
  }
3334
3399
  }
@@ -3344,7 +3409,7 @@ export class AmLyrics extends LitElement {
3344
3409
  this.backgroundWordProgress.clear();
3345
3410
  this.mainWordAnimations.clear();
3346
3411
  this.backgroundWordAnimations.clear();
3347
- this.isUserScrolling = false;
3412
+ this.setUserScrolling(false);
3348
3413
 
3349
3414
  // Cancel any running animations
3350
3415
  if (this.animationFrameId) {
@@ -3403,9 +3468,7 @@ export class AmLyrics extends LitElement {
3403
3468
  );
3404
3469
  if (targetLineIndex === null) return;
3405
3470
 
3406
- const targetLine = this.lyricsContainer.querySelector(
3407
- `#lyrics-line-${targetLineIndex}`,
3408
- ) as HTMLElement;
3471
+ const targetLine = this._getLineElement(targetLineIndex);
3409
3472
 
3410
3473
  if (targetLine) {
3411
3474
  this.focusLine(targetLine, forceScroll);
@@ -3430,9 +3493,167 @@ export class AmLyrics extends LitElement {
3430
3493
  return 0;
3431
3494
  }
3432
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
+
3433
3652
  private _updateCharTimingData() {
3434
3653
  if (!this.shadowRoot) return;
3435
3654
 
3655
+ this._rebuildDomCache();
3656
+
3436
3657
  // Get the computed font from the first syllable to ensure accuracy
3437
3658
  const referenceSyllable = this.shadowRoot.querySelector('.lyrics-syllable');
3438
3659
  if (!referenceSyllable) return;
@@ -3587,6 +3808,15 @@ export class AmLyrics extends LitElement {
3587
3808
  }
3588
3809
  }
3589
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
+
3590
3820
  private handleUserScroll() {
3591
3821
  // Ignore programmatic scrolls and click-seek scrolls
3592
3822
  if (this.isProgrammaticScroll || this.isClickSeeking) {
@@ -3594,8 +3824,7 @@ export class AmLyrics extends LitElement {
3594
3824
  }
3595
3825
 
3596
3826
  // Mark that user is currently scrolling
3597
- this.isUserScrolling = true;
3598
- this.lyricsContainer?.classList.add('user-scrolling');
3827
+ this.setUserScrolling(true);
3599
3828
 
3600
3829
  // Clear any existing timeout
3601
3830
  if (this.userScrollTimeoutId) {
@@ -3604,7 +3833,7 @@ export class AmLyrics extends LitElement {
3604
3833
 
3605
3834
  // Set timeout to re-enable auto-scroll after 2 seconds of no scrolling
3606
3835
  this.userScrollTimeoutId = window.setTimeout(() => {
3607
- this.isUserScrolling = false;
3836
+ this.setUserScrolling(false);
3608
3837
  this.userScrollTimeoutId = undefined;
3609
3838
 
3610
3839
  // Optionally scroll back to current active line when re-enabling auto-scroll
@@ -3615,20 +3844,16 @@ export class AmLyrics extends LitElement {
3615
3844
  }
3616
3845
 
3617
3846
  private findActiveLineIndices(time: number): number[] {
3618
- if (!this.lyrics) return [];
3847
+ if (!this.lyrics || this.lyrics.length === 0) return [];
3619
3848
  const activeLines: number[] = [];
3849
+
3620
3850
  for (let i = 0; i < this.lyrics.length; i += 1) {
3621
3851
  const line = this.lyrics[i];
3622
3852
  let effectiveEndTime = line.endtime;
3623
3853
 
3624
- // Extend the "active" highlight window to abut the next line,
3625
- // leaving a 500ms gap for breathing/scrolling
3626
3854
  if (i < this.lyrics.length - 1) {
3627
3855
  const nextLineStart = this.lyrics[i + 1].timestamp;
3628
3856
  const gapDuration = nextLineStart - line.endtime;
3629
-
3630
- // If the gap is large enough to trigger the breathing dots,
3631
- // DO NOT extend the highlight. The text should dim when the dots appear.
3632
3857
  if (gapDuration < INSTRUMENTAL_THRESHOLD_MS) {
3633
3858
  if (effectiveEndTime < nextLineStart) {
3634
3859
  effectiveEndTime = Math.max(effectiveEndTime, nextLineStart - 500);
@@ -3636,6 +3861,7 @@ export class AmLyrics extends LitElement {
3636
3861
  }
3637
3862
  }
3638
3863
 
3864
+ if (line.timestamp > time) break;
3639
3865
  if (time >= line.timestamp && time <= effectiveEndTime) {
3640
3866
  activeLines.push(i);
3641
3867
  }
@@ -3685,6 +3911,7 @@ export class AmLyrics extends LitElement {
3685
3911
  gapStart: number;
3686
3912
  gapEnd: number;
3687
3913
  }> {
3914
+ if (this.cachedAllGaps.length > 0) return this.cachedAllGaps;
3688
3915
  if (!this.lyrics || this.lyrics.length === 0) return [];
3689
3916
  const gaps: Array<{
3690
3917
  insertBeforeIndex: number;
@@ -3709,6 +3936,7 @@ export class AmLyrics extends LitElement {
3709
3936
  }
3710
3937
  }
3711
3938
 
3939
+ this.cachedAllGaps = gaps;
3712
3940
  return gaps;
3713
3941
  }
3714
3942
 
@@ -3898,7 +4126,7 @@ export class AmLyrics extends LitElement {
3898
4126
  clearTimeout(this.userScrollTimeoutId);
3899
4127
  this.userScrollTimeoutId = undefined;
3900
4128
  }
3901
- this.isUserScrolling = false;
4129
+ this.setUserScrolling(false);
3902
4130
 
3903
4131
  // Reset active line tracking to prevent scroll fighting
3904
4132
  this.currentPrimaryActiveLine = null;
@@ -4289,7 +4517,7 @@ export class AmLyrics extends LitElement {
4289
4517
 
4290
4518
  this.lyricsContainer.classList.remove('not-focused', 'user-scrolling');
4291
4519
  this.isProgrammaticScroll = true;
4292
- this.isUserScrolling = false;
4520
+ this.setUserScrolling(false);
4293
4521
 
4294
4522
  if (this.userScrollTimeoutId) {
4295
4523
  clearTimeout(this.userScrollTimeoutId);
@@ -4344,71 +4572,29 @@ export class AmLyrics extends LitElement {
4344
4572
  value: string;
4345
4573
  }> = [];
4346
4574
 
4347
- // Step 1 & 2: Apply animations
4575
+ // Step 1: Grow Pass
4348
4576
  if (isGrowable && isFirstSyllable && allWordCharSpans.length > 0) {
4349
- // Glow AND wipe applied to ALL characters simultaneously from the first syllable
4350
- // This prevents CSS animation restarts because the `animation` property is set once.
4351
-
4352
- const firstSyllableStartTime = parseFloat(
4353
- syllable.getAttribute('data-start-time') || '0',
4354
- );
4577
+ const finalDuration = wordDurationMs;
4578
+ const baseDelayPerChar = finalDuration * 0.09;
4579
+ const growDurationMs = finalDuration * 1.5;
4355
4580
 
4356
- allWordCharSpans.forEach((span, charIndexInWord) => {
4581
+ allWordCharSpans.forEach(span => {
4357
4582
  const horizontalOffset = parseFloat(
4358
4583
  span.dataset.horizontalOffset || '0',
4359
4584
  );
4360
-
4361
4585
  const maxScale = span.dataset.maxScale || '1.1';
4362
4586
  const shadowIntensity = span.dataset.shadowIntensity || '0.6';
4363
4587
  const translateYPeak = span.dataset.translateYPeak || '-2';
4364
4588
 
4365
- const animationParts: string[] = [];
4366
-
4367
- const parentSyllable = span.closest('.lyrics-syllable');
4368
- if (parentSyllable) {
4369
- const parentDuration = parseFloat(
4370
- parentSyllable.getAttribute('data-duration') || '0',
4371
- );
4372
- const parentStartTime = parseFloat(
4373
- parentSyllable.getAttribute('data-start-time') || '0',
4374
- );
4375
-
4376
- const startPct = parseFloat(span.dataset.wipeStart || '0');
4377
- const durationPct = parseFloat(span.dataset.wipeDuration || '0');
4378
-
4379
- const relativeStartOffset = Math.max(
4380
- 0,
4381
- parentStartTime - firstSyllableStartTime,
4382
- );
4383
- const wipeDelay = relativeStartOffset + parentDuration * startPct;
4384
- const wipeDuration = parentDuration * durationPct;
4385
-
4386
- const useStartAnimation = isFirstInContainer && charIndexInWord === 0;
4387
- let charWipeAnimation = 'wipe';
4388
- if (useStartAnimation)
4389
- charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
4390
- else charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
4391
-
4392
- // Blend word and syllable durations to let the gradient flow smoothly
4393
- // while still responding to syllable pacing (no strict exactness, just natural flow)
4394
- const growDelay = wipeDelay;
4395
- const growDurationMs = Math.max(
4396
- 600,
4397
- wordDurationMs * 0.8 + parentDuration * 1.5,
4398
- );
4399
-
4400
- animationParts.push(
4401
- `grow-dynamic ${growDurationMs}ms ease-in-out ${growDelay}ms forwards`,
4402
- );
4403
-
4404
- if (wipeDuration > 0) {
4405
- animationParts.push(
4406
- `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`,
4407
- );
4408
- }
4409
- }
4589
+ const syllableCharIndex = parseFloat(
4590
+ span.dataset.syllableCharIndex || '0',
4591
+ );
4592
+ const growDelay = baseDelayPerChar * syllableCharIndex;
4410
4593
 
4411
- charAnimationsMap.set(span, animationParts.join(', '));
4594
+ charAnimationsMap.set(
4595
+ span,
4596
+ `grow-dynamic ${growDurationMs}ms ease-in-out ${growDelay}ms forwards`,
4597
+ );
4412
4598
 
4413
4599
  styleUpdates.push({
4414
4600
  element: span,
@@ -4431,31 +4617,10 @@ export class AmLyrics extends LitElement {
4431
4617
  value: `${translateYPeak}`,
4432
4618
  });
4433
4619
  });
4434
- } else if (isGrowable && !isFirstSyllable && charSpans.length > 0) {
4435
- // For subsequent syllables of a growable word:
4436
- // If they already have `grow-dynamic`, it means the first syllable correctly took care of BOTH grow and wipe!
4437
- // Otherwise, they scrubbed directly into this syllable, so let's at least do the wipe.
4438
- charSpans.forEach(span => {
4439
- const existingAnimation =
4440
- charAnimationsMap.get(span) || span.style.animation || '';
4441
- if (existingAnimation.includes('grow-dynamic')) return;
4442
-
4443
- const startPct = parseFloat(span.dataset.wipeStart || '0');
4444
- const durationPct = parseFloat(span.dataset.wipeDuration || '0');
4445
- const wipeDelay = syllableDurationMs * startPct;
4446
- const wipeDuration = syllableDurationMs * durationPct;
4447
-
4448
- const charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
4620
+ }
4449
4621
 
4450
- if (wipeDuration > 0) {
4451
- charAnimationsMap.set(
4452
- span,
4453
- `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`,
4454
- );
4455
- }
4456
- });
4457
- } else if (charSpans.length > 0) {
4458
- // Per-character wipe for non-growable words (matching YouLyPlus)
4622
+ // Step 2: Wipe Pass
4623
+ if (charSpans.length > 0) {
4459
4624
  charSpans.forEach((span, charIndex) => {
4460
4625
  const startPct = parseFloat(span.dataset.wipeStart || '0');
4461
4626
  const durationPct = parseFloat(span.dataset.wipeDuration || '0');
@@ -4471,12 +4636,39 @@ export class AmLyrics extends LitElement {
4471
4636
  charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
4472
4637
  }
4473
4638
 
4639
+ const existingAnimation =
4640
+ charAnimationsMap.get(span) || span.style.animation || '';
4641
+
4642
+ const animationParts = [];
4643
+
4644
+ if (existingAnimation && existingAnimation.includes('grow-dynamic')) {
4645
+ animationParts.push(existingAnimation.split(',')[0].trim());
4646
+ }
4647
+ if (charIndex > 0) {
4648
+ const arrivalTime = span.dataset.preWipeArrival
4649
+ ? parseFloat(span.dataset.preWipeArrival)
4650
+ : wipeDelay;
4651
+ const constantDuration = parseFloat(
4652
+ span.dataset.preWipeDuration || '100',
4653
+ );
4654
+ const animDelay = arrivalTime - constantDuration;
4655
+
4656
+ if (constantDuration > 0) {
4657
+ animationParts.push(
4658
+ `pre-wipe-char ${constantDuration}ms linear ${animDelay}ms forwards`,
4659
+ );
4660
+ }
4661
+ }
4662
+
4474
4663
  if (wipeDuration > 0) {
4475
- charAnimationsMap.set(
4476
- span,
4664
+ animationParts.push(
4477
4665
  `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`,
4478
4666
  );
4479
4667
  }
4668
+
4669
+ if (animationParts.length > 0) {
4670
+ charAnimationsMap.set(span, animationParts.join(', '));
4671
+ }
4480
4672
  });
4481
4673
  } else {
4482
4674
  // Syllable-level wipe for regular (non-growable) words without chars
@@ -4925,10 +5117,7 @@ export class AmLyrics extends LitElement {
4925
5117
 
4926
5118
  const sourceLabel = this.lyricsSource ?? 'Unavailable';
4927
5119
 
4928
- const isUnsynced =
4929
- this.lyrics && this.lyrics.length > 0
4930
- ? this.lyrics.every(l => l.timestamp === 0 && l.endtime === 0)
4931
- : false;
5120
+ const isUnsynced = this.cachedIsUnsynced;
4932
5121
 
4933
5122
  const renderContent = () => {
4934
5123
  if (this.isLoading) {
@@ -5014,104 +5203,13 @@ export class AmLyrics extends LitElement {
5014
5203
  // translation/romanization block for background — it would just duplicate
5015
5204
  // the main line's text.
5016
5205
 
5017
- // Group syllables by word: when part=true, append to previous word group
5018
- const wordGroups: Syllable[][] = [];
5019
- for (const syllable of line.text) {
5020
- if (syllable.part && wordGroups.length > 0) {
5021
- // Continuation of previous word
5022
- wordGroups[wordGroups.length - 1].push(syllable);
5023
- } else {
5024
- // New word
5025
- wordGroups.push([syllable]);
5026
- }
5027
- }
5028
-
5029
- // Pre-compute isGrowable per "visual word": adjacent groups whose text
5030
- // doesn't end with whitespace form one visual word (e.g. "a"+"live" = "alive").
5031
- // We evaluate growable on the combined text/duration, then propagate
5032
- // the result to each individual group so it renders through the
5033
- // single-syllable path (which supports char-level glow).
5034
- const groupGrowable: boolean[] = new Array(wordGroups.length).fill(
5035
- false,
5036
- );
5037
- const groupGlowing: boolean[] = new Array(wordGroups.length).fill(
5038
- false,
5039
- );
5040
- // Visual word info for growable char-level glow:
5041
- // Each group stores the combined visual word's text, duration, and
5042
- // the char offset of this group within the visual word.
5043
- const vwFullText: string[] = new Array(wordGroups.length).fill('');
5044
- const vwFullDuration: number[] = new Array(wordGroups.length).fill(0);
5045
- const vwCharOffset: number[] = new Array(wordGroups.length).fill(0);
5046
- const vwStartMs: number[] = new Array(wordGroups.length).fill(0);
5047
- const vwEndMs: number[] = new Array(wordGroups.length).fill(0);
5048
- {
5049
- let vwStart = 0;
5050
- while (vwStart < wordGroups.length) {
5051
- let vwEnd = vwStart;
5052
- while (vwEnd < wordGroups.length - 1) {
5053
- const grp = wordGroups[vwEnd];
5054
- const lastText = grp[grp.length - 1].text;
5055
- if (/\s$/.test(lastText)) break;
5056
- vwEnd += 1;
5057
- }
5058
-
5059
- // Compute combined properties for this visual word
5060
- const combinedText = wordGroups
5061
- .slice(vwStart, vwEnd + 1)
5062
- .flatMap(g => g.map(s => s.text))
5063
- .join('')
5064
- .trim();
5065
- const combinedStart = wordGroups[vwStart][0].timestamp;
5066
- const lastGrp = wordGroups[vwEnd];
5067
- const combinedEnd = lastGrp[lastGrp.length - 1].endtime;
5068
- const combinedDuration = combinedEnd - combinedStart;
5069
-
5070
- const isCJK =
5071
- /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(
5072
- combinedText,
5073
- );
5074
- const isRTL =
5075
- /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF]/.test(
5076
- combinedText,
5077
- );
5078
- const hasHyphen = combinedText.includes('-');
5079
-
5080
- const wordLen = combinedText.length;
5081
- let isGrowableVW =
5082
- !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 12;
5083
- if (isGrowableVW) {
5084
- if (wordLen < 3) {
5085
- isGrowableVW =
5086
- combinedDuration >= 1110 && combinedDuration >= wordLen * 550;
5087
- } else {
5088
- isGrowableVW =
5089
- combinedDuration >= 850 && combinedDuration >= wordLen * 200;
5090
- }
5091
- }
5092
-
5093
- // Glow requirement (more strict)
5094
- const isGlowingVW =
5095
- isGrowableVW &&
5096
- combinedDuration >= 1000 &&
5097
- combinedDuration >= combinedText.length * 250;
5098
-
5099
- let charOff = 0;
5100
- for (let gi = vwStart; gi <= vwEnd; gi += 1) {
5101
- groupGrowable[gi] = isGrowableVW;
5102
- groupGlowing[gi] = isGlowingVW;
5103
- vwFullText[gi] = combinedText;
5104
- vwFullDuration[gi] = combinedDuration;
5105
- vwCharOffset[gi] = charOff;
5106
- vwStartMs[gi] = combinedStart;
5107
- vwEndMs[gi] = combinedEnd;
5108
- const grpText = wordGroups[gi].map(s => s.text).join('');
5109
- charOff += grpText.replace(/\s/g, '').length;
5110
- }
5111
-
5112
- vwStart = vwEnd + 1;
5113
- }
5114
- }
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 ?? [];
5115
5213
 
5116
5214
  // Create main vocals using YouLyPlus syllable structure
5117
5215
  const mainVocalElement = html`<p class="main-vocal-container">
@@ -5387,9 +5485,7 @@ export class AmLyrics extends LitElement {
5387
5485
  <div
5388
5486
  class="lyrics-container ${isUnsynced
5389
5487
  ? 'is-unsynced'
5390
- : 'blur-inactive-enabled'} ${this.isUserScrolling
5391
- ? 'user-scrolling'
5392
- : ''}"
5488
+ : 'blur-inactive-enabled'}"
5393
5489
  >
5394
5490
  ${!this.isLoading && this.lyrics && this.lyrics.length > 0
5395
5491
  ? html`
@@ -5504,17 +5600,15 @@ export class AmLyrics extends LitElement {
5504
5600
  !this.hasFetchedAllProviders
5505
5601
  ? html`
5506
5602
  <button
5507
- class="download-button"
5603
+ class="download-button source-switch-btn"
5508
5604
  title="Switch Lyrics Source"
5509
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;"
5510
5606
  @click=${this.switchSource}
5511
5607
  ?disabled=${this.isFetchingAlternatives}
5512
5608
  >
5513
5609
  <svg
5514
- style="margin-right: 4px; ${this
5515
- .isFetchingAlternatives
5516
- ? 'animation: spin 1s linear infinite;'
5517
- : ''}"
5610
+ class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
5611
+ style="margin-right: 4px;"
5518
5612
  xmlns="http://www.w3.org/2000/svg"
5519
5613
  width="12"
5520
5614
  height="12"
@@ -5524,7 +5618,6 @@ export class AmLyrics extends LitElement {
5524
5618
  stroke-width="2"
5525
5619
  stroke-linecap="round"
5526
5620
  stroke-linejoin="round"
5527
- class="lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
5528
5621
  >
5529
5622
  ${this.isFetchingAlternatives
5530
5623
  ? svg`<path
@@ -5535,9 +5628,11 @@ export class AmLyrics extends LitElement {
5535
5628
  ><path d="m21 8-4-4-4 4"></path
5536
5629
  ><path d="M17 4v16"></path>`}
5537
5630
  </svg>
5538
- ${this.isFetchingAlternatives
5539
- ? 'Switching...'
5540
- : 'Switch'}
5631
+ <span class="source-switch-label"
5632
+ >${this.isFetchingAlternatives
5633
+ ? 'Switching...'
5634
+ : 'Switch'}</span
5635
+ >
5541
5636
  </button>
5542
5637
  `
5543
5638
  : ''}