@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/dist/src/AmLyrics.d.ts +13 -0
- package/dist/src/AmLyrics.d.ts.map +1 -1
- package/dist/src/am-lyrics.js +287 -182
- package/dist/src/am-lyrics.js.map +1 -1
- package/dist/src/react.js +287 -182
- package/dist/src/react.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/AmLyrics.ts +341 -209
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.
|
|
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.
|
|
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.
|
|
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');
|
|
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.
|
|
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
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
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
|
-
}
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
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
|
-
}
|
|
3243
|
-
|
|
3244
|
-
|
|
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.
|
|
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;
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
4981
|
-
const wordGroups
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
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'}
|
|
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
|
-
|
|
5478
|
-
|
|
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
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5631
|
+
<span class="source-switch-label"
|
|
5632
|
+
>${this.isFetchingAlternatives
|
|
5633
|
+
? 'Switching...'
|
|
5634
|
+
: 'Switch'}</span
|
|
5635
|
+
>
|
|
5504
5636
|
</button>
|
|
5505
5637
|
`
|
|
5506
5638
|
: ''}
|