@uimaxbai/am-lyrics 1.1.7 → 1.2.0

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,8 +2,34 @@ 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.1.7';
5
+ const VERSION = '1.2.0';
6
6
  const INSTRUMENTAL_THRESHOLD_MS = 7000; // Show dots for gaps >= 7s
7
+ const FETCH_TIMEOUT_MS = 8000; // Timeout for all lyrics fetch requests
8
+ const SEEK_THRESHOLD_MS = 500;
9
+ const PRE_SCROLL_LEAD_MS = 500;
10
+ const SCROLL_ANIMATION_DURATION_MS = 280;
11
+ const SCROLL_DELAY_INCREMENT_MS = 24;
12
+ const GAP_PULSE_DURATION_MS = 4000;
13
+ const GAP_PULSE_CYCLE_MS = GAP_PULSE_DURATION_MS * 2;
14
+ const GAP_EXIT_LEAD_MS = 360;
15
+ const GAP_MIN_SCALE = 0.85;
16
+
17
+ /**
18
+ * Fetch with an automatic timeout via AbortSignal.
19
+ * Rejects if the request takes longer than `timeoutMs`.
20
+ */
21
+ function fetchWithTimeout(
22
+ url: string,
23
+ options: Parameters<typeof fetch>[1] = {},
24
+ timeoutMs = FETCH_TIMEOUT_MS,
25
+ ): Promise<Response> {
26
+ const controller = new AbortController();
27
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
28
+
29
+ return fetch(url, { ...options, signal: controller.signal }).finally(() =>
30
+ clearTimeout(timeoutId),
31
+ );
32
+ }
7
33
 
8
34
  const KPOE_SERVERS = [
9
35
  'https://lyricsplus.binimum.org',
@@ -172,7 +198,7 @@ export class AmLyrics extends LitElement {
172
198
  .lyrics-line.scroll-animate {
173
199
  transition: none !important; /* Prevent conflict with scroll animation */
174
200
  animation-name: lyrics-scroll;
175
- animation-duration: 400ms;
201
+ animation-duration: var(--scroll-duration, 280ms);
176
202
  animation-timing-function: cubic-bezier(0.41, 0, 0.12, 0.99);
177
203
  animation-fill-mode: both;
178
204
  animation-delay: var(--lyrics-line-delay, 0ms);
@@ -244,19 +270,20 @@ export class AmLyrics extends LitElement {
244
270
  opacity: 0;
245
271
  font-size: var(--lyplus-font-size-subtext);
246
272
  transition:
247
- max-height 0.3s,
248
- opacity 0.6s,
249
- padding 0.6s;
273
+ max-height 350ms cubic-bezier(0.33, 1, 0.68, 1),
274
+ opacity 300ms ease-out,
275
+ padding 350ms cubic-bezier(0.33, 1, 0.68, 1);
250
276
  margin: 0;
251
277
  }
252
278
 
253
- .lyrics-line.active .background-vocal-container {
279
+ .lyrics-line.active .background-vocal-container,
280
+ .lyrics-line.pre-active .background-vocal-container {
254
281
  max-height: 4em;
255
282
  opacity: 1;
256
283
  transition:
257
- max-height 0.6s,
258
- opacity 0.6s,
259
- padding 0.6s;
284
+ max-height 350ms cubic-bezier(0.22, 1, 0.36, 1),
285
+ opacity 300ms ease-out,
286
+ padding 350ms cubic-bezier(0.22, 1, 0.36, 1);
260
287
  will-change: max-height, opacity, padding;
261
288
  }
262
289
 
@@ -267,6 +294,11 @@ export class AmLyrics extends LitElement {
267
294
  will-change: transform, opacity;
268
295
  }
269
296
 
297
+ .lyrics-line.pre-active {
298
+ opacity: 1;
299
+ will-change: transform, opacity;
300
+ }
301
+
270
302
  .lyrics-line.singer-right {
271
303
  text-align: end;
272
304
  }
@@ -346,7 +378,7 @@ export class AmLyrics extends LitElement {
346
378
  /* Unblur early for pre-active lines */
347
379
  .lyrics-container.blur-inactive-enabled .lyrics-line.pre-active {
348
380
  filter: blur(0px) !important;
349
- opacity: var(--lyplus-primary-opacity);
381
+ opacity: 1;
350
382
  }
351
383
 
352
384
  /* ==========================================================================
@@ -584,43 +616,45 @@ export class AmLyrics extends LitElement {
584
616
  INSTRUMENTAL GAP STYLES
585
617
  ========================================================================== */
586
618
  .lyrics-gap {
587
- height: 0;
619
+ max-height: 0;
588
620
  padding: 0 var(--lyplus-padding-gap);
589
621
  overflow: hidden;
590
622
  opacity: 0;
591
623
  box-sizing: content-box;
592
624
  background-clip: unset;
625
+ transform-origin: top;
593
626
  transition:
594
- padding 0.3s 0.5s,
595
- height 0.3s 0.5s,
596
- opacity 0.2s 0.5s,
597
- transform 0.3s var(--lyrics-line-delay, 0ms);
627
+ padding 220ms cubic-bezier(0.33, 1, 0.68, 1),
628
+ max-height 220ms cubic-bezier(0.33, 1, 0.68, 1),
629
+ opacity 160ms ease-out,
630
+ transform var(--scroll-duration, 280ms) var(--lyrics-line-delay, 0ms);
598
631
  }
599
632
 
600
633
  .lyrics-gap.active {
601
- height: 1.3em;
634
+ max-height: 1.6em;
602
635
  padding: var(--lyplus-padding-gap);
603
636
  opacity: 1;
604
637
  overflow: visible;
605
638
  transition:
606
- padding 0.3s,
607
- height 0.3s,
608
- opacity 0.2s 0.3s,
609
- transform 0.3s;
610
- will-change: height, opacity, padding;
639
+ padding 220ms cubic-bezier(0.22, 1, 0.36, 1),
640
+ max-height 220ms cubic-bezier(0.22, 1, 0.36, 1),
641
+ opacity 160ms ease-out,
642
+ transform var(--scroll-duration, 280ms);
643
+ will-change: max-height, opacity, padding;
611
644
  }
612
645
 
613
- /* Exiting state: keep gap visible while dots animate out */
646
+ /* Exiting state: quickly collapse width and height so dots don't distort page, or remove max-height transition */
614
647
  .lyrics-gap.gap-exiting {
615
- height: 1.3em;
616
- padding: var(--lyplus-padding-gap);
617
- opacity: 1;
648
+ max-height: 0;
649
+ padding: 0 var(--lyplus-padding-gap);
650
+ opacity: 0;
618
651
  overflow: visible;
619
652
  transition:
620
- padding 0.3s 0.5s,
621
- height 0.3s 0.5s,
622
- opacity 0.2s 0.5s,
623
- transform 0.3s;
653
+ padding var(--gap-exit-duration, 360ms) cubic-bezier(0.33, 1, 0.68, 1),
654
+ max-height var(--gap-exit-duration, 360ms)
655
+ cubic-bezier(0.33, 1, 0.68, 1),
656
+ opacity 160ms ease-out,
657
+ transform var(--scroll-duration, 280ms);
624
658
  }
625
659
 
626
660
  .lyrics-gap .main-vocal-container {
@@ -629,7 +663,8 @@ export class AmLyrics extends LitElement {
629
663
 
630
664
  /* Jump animation plays during exit */
631
665
  .lyrics-gap.gap-exiting .main-vocal-container {
632
- animation: gap-ended 0.8s ease forwards;
666
+ animation: gap-ended var(--gap-exit-duration, 360ms)
667
+ cubic-bezier(0.33, 1, 0.68, 1) forwards;
633
668
  }
634
669
 
635
670
  .lyrics-gap:not(.active):not(.gap-exiting) .main-vocal-container {
@@ -643,7 +678,9 @@ export class AmLyrics extends LitElement {
643
678
  }
644
679
 
645
680
  .lyrics-gap.active .main-vocal-container .lyrics-word {
646
- animation: gap-loop 4s ease infinite alternate;
681
+ animation: gap-loop var(--gap-pulse-duration, 4000ms) ease-in-out infinite
682
+ alternate;
683
+ animation-delay: var(--gap-loop-delay, 0ms);
647
684
  will-change: transform;
648
685
  }
649
686
 
@@ -667,6 +704,18 @@ export class AmLyrics extends LitElement {
667
704
  color: var(--lyplus-text-primary) !important;
668
705
  }
669
706
 
707
+ .lyrics-line.pre-active .lyrics-syllable.line-synced {
708
+ animation: fade-in-line 0.14s ease-out forwards !important;
709
+ color: var(--lyplus-text-primary) !important;
710
+ }
711
+
712
+ .lyrics-line.active .lyrics-syllable.line-synced span.char,
713
+ .lyrics-line.pre-active .lyrics-syllable.line-synced span.char {
714
+ background-image: none !important;
715
+ background-color: var(--lyplus-text-primary) !important;
716
+ transition: background-color 120ms ease-out !important;
717
+ }
718
+
670
719
  @keyframes fade-in-line {
671
720
  from {
672
721
  opacity: 0.5;
@@ -1080,19 +1129,20 @@ export class AmLyrics extends LitElement {
1080
1129
  /* Gap dot animations */
1081
1130
  @keyframes gap-loop {
1082
1131
  from {
1083
- transform: scale(1.15);
1132
+ transform: scale(1.12);
1084
1133
  }
1085
1134
  to {
1086
- transform: scale(0.85);
1135
+ transform: scale(var(--gap-exit-scale, 0.85));
1087
1136
  }
1088
1137
  }
1089
1138
 
1090
1139
  @keyframes gap-ended {
1091
1140
  0% {
1092
- transform: translateY(-25%) scale(1) translateZ(0);
1141
+ transform: translateY(-25%) scale(var(--gap-exit-scale, 0.85))
1142
+ translateZ(0);
1093
1143
  }
1094
1144
  35% {
1095
- transform: translateY(-25%) scale(1.2) translateZ(0);
1145
+ transform: translateY(-5%) scale(1.08) translateZ(0);
1096
1146
  }
1097
1147
  100% {
1098
1148
  transform: translateY(-25%) scale(0) translateZ(0);
@@ -1491,11 +1541,19 @@ export class AmLyrics extends LitElement {
1491
1541
 
1492
1542
  private scrollAnimationTimeout?: ReturnType<typeof setTimeout>;
1493
1543
 
1544
+ // AbortController for cancelling in-flight lyrics fetches
1545
+ private fetchAbortController?: AbortController;
1546
+
1494
1547
  // Syllable animation tracking
1495
1548
  private lastActiveIndex = 0;
1496
1549
 
1497
1550
  private visibleLineIds: Set<string> = new Set();
1498
1551
 
1552
+ // Bound handler references for proper event listener removal
1553
+ private _boundHandleUserScroll = this.handleUserScroll.bind(this);
1554
+
1555
+ private _boundAnimateProgress = this.animateProgress.bind(this);
1556
+
1499
1557
  connectedCallback() {
1500
1558
  super.connectedCallback();
1501
1559
  this.fetchLyrics();
@@ -1505,13 +1563,46 @@ export class AmLyrics extends LitElement {
1505
1563
  super.disconnectedCallback();
1506
1564
  if (this.animationFrameId) {
1507
1565
  cancelAnimationFrame(this.animationFrameId);
1566
+ this.animationFrameId = undefined;
1508
1567
  }
1509
1568
  if (this.userScrollTimeoutId) {
1510
1569
  clearTimeout(this.userScrollTimeoutId);
1570
+ this.userScrollTimeoutId = undefined;
1571
+ }
1572
+ if (this.clickSeekTimeout) {
1573
+ clearTimeout(this.clickSeekTimeout);
1574
+ this.clickSeekTimeout = undefined;
1575
+ }
1576
+ if (this.scrollUnlockTimeout) {
1577
+ clearTimeout(this.scrollUnlockTimeout);
1578
+ this.scrollUnlockTimeout = undefined;
1579
+ }
1580
+ if (this.scrollAnimationTimeout) {
1581
+ clearTimeout(this.scrollAnimationTimeout);
1582
+ this.scrollAnimationTimeout = undefined;
1583
+ }
1584
+ // Cancel any in-flight fetch requests
1585
+ this.fetchAbortController?.abort();
1586
+ this.fetchAbortController = undefined;
1587
+ // Remove scroll event listeners
1588
+ if (this.lyricsContainer) {
1589
+ this.lyricsContainer.removeEventListener(
1590
+ 'wheel',
1591
+ this._boundHandleUserScroll,
1592
+ );
1593
+ this.lyricsContainer.removeEventListener(
1594
+ 'touchmove',
1595
+ this._boundHandleUserScroll,
1596
+ );
1511
1597
  }
1512
1598
  }
1513
1599
 
1514
1600
  private async fetchLyrics() {
1601
+ // Cancel any in-flight fetch to prevent stale results from racing
1602
+ this.fetchAbortController?.abort();
1603
+ const controller = new AbortController();
1604
+ this.fetchAbortController = controller;
1605
+
1515
1606
  this.isLoading = true;
1516
1607
  this.lyrics = undefined;
1517
1608
  this.lyricsSource = null;
@@ -1521,6 +1612,8 @@ export class AmLyrics extends LitElement {
1521
1612
  this.hasFetchedAllProviders = false;
1522
1613
  try {
1523
1614
  const resolvedMetadata = await this.resolveSongMetadata();
1615
+ // If a newer fetch was triggered while we awaited, bail out
1616
+ if (controller.signal.aborted) return;
1524
1617
 
1525
1618
  const isMusicIdOnlyRequest =
1526
1619
  Boolean(this.musicId) &&
@@ -1605,7 +1698,10 @@ export class AmLyrics extends LitElement {
1605
1698
  this.lyrics = undefined;
1606
1699
  this.lyricsSource = null;
1607
1700
  } finally {
1608
- this.isLoading = false;
1701
+ // Only update loading state if this fetch wasn't superseded
1702
+ if (!controller.signal.aborted) {
1703
+ this.isLoading = false;
1704
+ }
1609
1705
  }
1610
1706
  }
1611
1707
 
@@ -1932,7 +2028,7 @@ export class AmLyrics extends LitElement {
1932
2028
 
1933
2029
  try {
1934
2030
  // eslint-disable-next-line no-await-in-loop
1935
- const response = await fetch(url);
2031
+ const response = await fetchWithTimeout(url);
1936
2032
  if (response.ok) {
1937
2033
  // eslint-disable-next-line no-await-in-loop
1938
2034
  const payload = await response.json();
@@ -1983,7 +2079,9 @@ export class AmLyrics extends LitElement {
1983
2079
  );
1984
2080
  }
1985
2081
 
1986
- params.append('source', DEFAULT_KPOE_SOURCE_ORDER);
2082
+ if (!DEFAULT_KPOE_SOURCE_ORDER.includes('apple')) {
2083
+ params.append('source', DEFAULT_KPOE_SOURCE_ORDER);
2084
+ }
1987
2085
 
1988
2086
  const getRank = (sourceLabel: string, parsedLines: any[]): number => {
1989
2087
  const lower = sourceLabel.toLowerCase();
@@ -2036,13 +2134,13 @@ export class AmLyrics extends LitElement {
2036
2134
  }
2037
2135
 
2038
2136
  const cacheUrl = `https://lyrics-api.binimum.org/?${cacheParams.toString()}`;
2039
- const cacheRes = await fetch(cacheUrl);
2137
+ const cacheRes = await fetchWithTimeout(cacheUrl);
2040
2138
  if (cacheRes.ok) {
2041
2139
  const cacheData = await cacheRes.json();
2042
2140
  if (cacheData.results && cacheData.results.length > 0) {
2043
2141
  const result = cacheData.results[0];
2044
2142
  if (result.timing_type === 'word' && result.lyricsUrl) {
2045
- const ttmlRes = await fetch(result.lyricsUrl);
2143
+ const ttmlRes = await fetchWithTimeout(result.lyricsUrl);
2046
2144
  if (ttmlRes.ok) {
2047
2145
  const ttmlText = await ttmlRes.text();
2048
2146
  const lines = AmLyrics.parseTTML(ttmlText);
@@ -2056,7 +2154,7 @@ export class AmLyrics extends LitElement {
2056
2154
  const fallbackParams = new URLSearchParams(params);
2057
2155
  const fallbackUrl = `https://lyricsplus.binimum.org/v2/lyrics/get?${fallbackParams.toString()}`;
2058
2156
  try {
2059
- const fallbackRes = await fetch(fallbackUrl);
2157
+ const fallbackRes = await fetchWithTimeout(fallbackUrl);
2060
2158
  if (fallbackRes.ok) {
2061
2159
  const payload = await fallbackRes.json();
2062
2160
  const lines = AmLyrics.convertKPoeLyrics(payload);
@@ -2081,7 +2179,7 @@ export class AmLyrics extends LitElement {
2081
2179
 
2082
2180
  // If fallback fails or has no word sync, fall back to bini lyrics
2083
2181
  if (result.lyricsUrl) {
2084
- const ttmlRes = await fetch(result.lyricsUrl);
2182
+ const ttmlRes = await fetchWithTimeout(result.lyricsUrl);
2085
2183
  if (ttmlRes.ok) {
2086
2184
  const ttmlText = await ttmlRes.text();
2087
2185
  const lines = AmLyrics.parseTTML(ttmlText);
@@ -2103,10 +2201,10 @@ export class AmLyrics extends LitElement {
2103
2201
  }
2104
2202
 
2105
2203
  // Shuffle servers so we pick a random one first, with all others as fallback
2106
- // Limit to 2 servers to prevent unnecessary API spam when Apple lyrics are missing
2204
+ // Try up to 3 servers to improve reliability when some have CORS or connectivity issues
2107
2205
  const shuffledServers = [...KPOE_SERVERS]
2108
2206
  .sort(() => Math.random() - 0.5)
2109
- .slice(0, 2);
2207
+ .slice(0, 3);
2110
2208
 
2111
2209
  for (const base of shuffledServers) {
2112
2210
  const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
@@ -2116,12 +2214,12 @@ export class AmLyrics extends LitElement {
2116
2214
 
2117
2215
  try {
2118
2216
  // eslint-disable-next-line no-await-in-loop
2119
- const response = await fetch(url);
2217
+ const response = await fetchWithTimeout(url);
2120
2218
  if (response.ok) {
2121
2219
  // eslint-disable-next-line no-await-in-loop
2122
2220
  payload = await response.json();
2123
2221
  }
2124
- } catch (error) {
2222
+ } catch {
2125
2223
  payload = null;
2126
2224
  }
2127
2225
 
@@ -2156,7 +2254,7 @@ export class AmLyrics extends LitElement {
2156
2254
  try {
2157
2255
  const fallbackParams = new URLSearchParams(params);
2158
2256
  const url = `https://lyricsplus.binimum.org/v2/lyrics/get?${fallbackParams.toString()}`;
2159
- const response = await fetch(url);
2257
+ const response = await fetchWithTimeout(url);
2160
2258
  if (response.ok) {
2161
2259
  const payload = await response.json();
2162
2260
  if (payload) {
@@ -2198,7 +2296,6 @@ export class AmLyrics extends LitElement {
2198
2296
  if (!match) {
2199
2297
  // Skip non-timestamped lines (headers like [ti:], [ar:], etc.)
2200
2298
  // eslint-disable-next-line no-continue
2201
- // eslint-disable-next-line no-continue
2202
2299
  continue;
2203
2300
  }
2204
2301
  const minutes = parseInt(match[1], 10);
@@ -2221,7 +2318,6 @@ export class AmLyrics extends LitElement {
2221
2318
 
2222
2319
  // Skip empty lines (instrumental gaps)
2223
2320
  if (!text.trim()) {
2224
- // eslint-disable-next-line no-continue
2225
2321
  // eslint-disable-next-line no-continue
2226
2322
  continue;
2227
2323
  }
@@ -2261,9 +2357,9 @@ export class AmLyrics extends LitElement {
2261
2357
 
2262
2358
  if (!title || !artist) return null;
2263
2359
 
2264
- // Pick 2 random unique servers
2360
+ // Pick 3 random unique servers for better reliability
2265
2361
  const shuffled = [...TIDAL_SERVERS].sort(() => Math.random() - 0.5);
2266
- const serversToTry = shuffled.slice(0, 2);
2362
+ const serversToTry = shuffled.slice(0, 3);
2267
2363
 
2268
2364
  for (const base of serversToTry) {
2269
2365
  try {
@@ -2273,12 +2369,11 @@ export class AmLyrics extends LitElement {
2273
2369
  const searchQuery = `${title} ${artist}`;
2274
2370
  const searchParams = new URLSearchParams({ s: searchQuery });
2275
2371
  // eslint-disable-next-line no-await-in-loop
2276
- const searchResponse = await fetch(
2372
+ const searchResponse = await fetchWithTimeout(
2277
2373
  `${normalizedBase}/search/?${searchParams.toString()}`,
2278
2374
  );
2279
2375
 
2280
2376
  if (!searchResponse.ok) {
2281
- // eslint-disable-next-line no-continue
2282
2377
  // eslint-disable-next-line no-continue
2283
2378
  continue;
2284
2379
  }
@@ -2288,7 +2383,6 @@ export class AmLyrics extends LitElement {
2288
2383
  const items = searchData?.data?.items;
2289
2384
 
2290
2385
  if (!Array.isArray(items) || items.length === 0) {
2291
- // eslint-disable-next-line no-continue
2292
2386
  // eslint-disable-next-line no-continue
2293
2387
  continue;
2294
2388
  }
@@ -2307,19 +2401,17 @@ export class AmLyrics extends LitElement {
2307
2401
 
2308
2402
  const trackId = bestTrack?.id;
2309
2403
  if (!trackId) {
2310
- // eslint-disable-next-line no-continue
2311
2404
  // eslint-disable-next-line no-continue
2312
2405
  continue;
2313
2406
  }
2314
2407
 
2315
2408
  // Step 2: Fetch lyrics
2316
2409
  // eslint-disable-next-line no-await-in-loop
2317
- const lyricsResponse = await fetch(
2410
+ const lyricsResponse = await fetchWithTimeout(
2318
2411
  `${normalizedBase}/lyrics/?id=${trackId}`,
2319
2412
  );
2320
2413
 
2321
2414
  if (!lyricsResponse.ok) {
2322
- // eslint-disable-next-line no-continue
2323
2415
  // eslint-disable-next-line no-continue
2324
2416
  continue;
2325
2417
  }
@@ -2361,7 +2453,7 @@ export class AmLyrics extends LitElement {
2361
2453
  try {
2362
2454
  const searchQuery = `${artist} ${title}`;
2363
2455
  const params = new URLSearchParams({ q: searchQuery });
2364
- const response = await fetch(
2456
+ const response = await fetchWithTimeout(
2365
2457
  `https://lrclib.net/api/search?${params.toString()}`,
2366
2458
  {
2367
2459
  headers: {
@@ -2433,7 +2525,9 @@ export class AmLyrics extends LitElement {
2433
2525
 
2434
2526
  try {
2435
2527
  const params = new URLSearchParams({ title, artist });
2436
- const response = await fetch(`${GENIUS_WORKER_URL}?${params.toString()}`);
2528
+ const response = await fetchWithTimeout(
2529
+ `${GENIUS_WORKER_URL}?${params.toString()}`,
2530
+ );
2437
2531
 
2438
2532
  if (!response.ok) return null;
2439
2533
  const data = await response.json();
@@ -2467,8 +2561,7 @@ export class AmLyrics extends LitElement {
2467
2561
  }
2468
2562
  }
2469
2563
  } catch {
2470
- // eslint-disable-next-line no-console
2471
- console.error('No Genius lyrics found');
2564
+ // Genius fetch failed, will fall through to return null
2472
2565
  }
2473
2566
 
2474
2567
  return null;
@@ -2546,7 +2639,7 @@ export class AmLyrics extends LitElement {
2546
2639
  const doc = parser.parseFromString(ttmlString, 'text/xml');
2547
2640
 
2548
2641
  const translations: Record<string, string> = {};
2549
- const transliterations: Record<string, string> = {};
2642
+ const transliterations: Record<string, any> = {};
2550
2643
  const agentMap: Record<string, string> = {};
2551
2644
 
2552
2645
  const agents = doc.getElementsByTagName('ttm:agent');
@@ -2571,20 +2664,6 @@ export class AmLyrics extends LitElement {
2571
2664
  }
2572
2665
  }
2573
2666
 
2574
- const transliterationNodes = doc.getElementsByTagName('transliteration');
2575
- for (let i = 0; i < transliterationNodes.length; i += 1) {
2576
- const texts = transliterationNodes[i].getElementsByTagName('text');
2577
- for (let j = 0; j < texts.length; j += 1) {
2578
- const textNode = texts[j];
2579
- const key = textNode.getAttribute('for');
2580
- if (key && textNode.textContent) {
2581
- transliterations[key] = textNode.textContent
2582
- .trim()
2583
- .replace(/\s+/g, ' ');
2584
- }
2585
- }
2586
- }
2587
-
2588
2667
  const timeToMs = (timeStr: string | null): number => {
2589
2668
  if (!timeStr) return 0;
2590
2669
  const parts = timeStr.split(':');
@@ -2602,6 +2681,59 @@ export class AmLyrics extends LitElement {
2602
2681
  return Math.round(seconds * 1000);
2603
2682
  };
2604
2683
 
2684
+ const transliterationNodes = doc.getElementsByTagName('transliteration');
2685
+ for (let i = 0; i < transliterationNodes.length; i += 1) {
2686
+ const texts = transliterationNodes[i].getElementsByTagName('text');
2687
+ for (let j = 0; j < texts.length; j += 1) {
2688
+ const textNode = texts[j];
2689
+ const key = textNode.getAttribute('for');
2690
+ if (!key) {
2691
+ // eslint-disable-next-line no-continue
2692
+ continue;
2693
+ }
2694
+
2695
+ const spans = Array.from(
2696
+ textNode.getElementsByTagName('span'),
2697
+ ).filter(span => span.getAttribute('begin'));
2698
+
2699
+ if (spans.length > 0) {
2700
+ const syllabus: any[] = [];
2701
+ let fullText = '';
2702
+ for (let k = 0; k < spans.length; k += 1) {
2703
+ const span = spans[k];
2704
+ const begin = span.getAttribute('begin');
2705
+ const end = span.getAttribute('end');
2706
+ let spanText = span.textContent || '';
2707
+ const nextNode = span.nextSibling;
2708
+ if (
2709
+ nextNode &&
2710
+ nextNode.nodeType === 3 &&
2711
+ /^\s/.test(nextNode.textContent || '') &&
2712
+ !spanText.endsWith(' ')
2713
+ ) {
2714
+ spanText += ' ';
2715
+ }
2716
+ if (spanText.trim() === '') {
2717
+ // eslint-disable-next-line no-continue
2718
+ continue;
2719
+ }
2720
+
2721
+ syllabus.push({
2722
+ time: timeToMs(begin),
2723
+ duration: timeToMs(end) - timeToMs(begin),
2724
+ text: spanText,
2725
+ });
2726
+ fullText += spanText;
2727
+ }
2728
+ transliterations[key] = { text: fullText.trim(), syllabus };
2729
+ } else if (textNode.textContent) {
2730
+ transliterations[key] = {
2731
+ text: textNode.textContent.trim().replace(/\s+/g, ' '),
2732
+ };
2733
+ }
2734
+ }
2735
+ }
2736
+
2605
2737
  const lines: LyricsLine[] = [];
2606
2738
  const pNodes = doc.getElementsByTagName('p');
2607
2739
 
@@ -2697,6 +2829,74 @@ export class AmLyrics extends LitElement {
2697
2829
 
2698
2830
  const alignment = alignments[i];
2699
2831
 
2832
+ // Distribute line-level transliteration to individual syllables
2833
+ // so that per-syllable animated romanisation works (like KPoe lyrics)
2834
+ const lineTransliterationItem = key ? transliterations[key] : undefined;
2835
+ if (
2836
+ lineTransliterationItem &&
2837
+ mainSyllables.length > 1 &&
2838
+ spans.length > 0
2839
+ ) {
2840
+ if (
2841
+ lineTransliterationItem.syllabus &&
2842
+ lineTransliterationItem.syllabus.length === mainSyllables.length
2843
+ ) {
2844
+ mainSyllables.forEach((syl, mapIdx) => {
2845
+ // eslint-disable-next-line no-param-reassign
2846
+ syl.romanizedText = lineTransliterationItem.syllabus[mapIdx].text;
2847
+ });
2848
+ } else {
2849
+ const lineTransliteration = lineTransliterationItem.text;
2850
+ const romanWords = lineTransliteration.split(/\s+/).filter(Boolean);
2851
+
2852
+ const syllableGroups: number[][] = [];
2853
+ for (let si = 0; si < mainSyllables.length; si += 1) {
2854
+ if (mainSyllables[si].part && syllableGroups.length > 0) {
2855
+ syllableGroups[syllableGroups.length - 1].push(si);
2856
+ } else {
2857
+ syllableGroups.push([si]);
2858
+ }
2859
+ }
2860
+
2861
+ const isCJK =
2862
+ /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(
2863
+ mainSyllables.map(s => s.text).join(''),
2864
+ );
2865
+
2866
+ if (romanWords.length === syllableGroups.length) {
2867
+ syllableGroups.forEach((group, gi) => {
2868
+ // eslint-disable-next-line no-param-reassign
2869
+ mainSyllables[group[0]].romanizedText = romanWords[gi];
2870
+ });
2871
+ } else if (romanWords.length === mainSyllables.length) {
2872
+ mainSyllables.forEach((syl, mapIdx) => {
2873
+ // eslint-disable-next-line no-param-reassign
2874
+ syl.romanizedText = romanWords[mapIdx];
2875
+ });
2876
+ } else if (isCJK) {
2877
+ let romanIdx = 0;
2878
+ for (const group of syllableGroups) {
2879
+ const syl = mainSyllables[group[0]];
2880
+ const sylText = group
2881
+ .map(gIndex => mainSyllables[gIndex].text)
2882
+ .join('');
2883
+ const validChars =
2884
+ sylText.match(
2885
+ /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7afA-Za-z0-9]/g,
2886
+ ) || [];
2887
+ const needed = validChars.length;
2888
+ if (needed > 0 && romanIdx < romanWords.length) {
2889
+ // eslint-disable-next-line no-param-reassign
2890
+ syl.romanizedText = romanWords
2891
+ .slice(romanIdx, romanIdx + needed)
2892
+ .join(' ');
2893
+ romanIdx += needed;
2894
+ }
2895
+ }
2896
+ }
2897
+ }
2898
+ }
2899
+
2700
2900
  lines.push({
2701
2901
  text: mainSyllables,
2702
2902
  background: bgSyllables.length > 0,
@@ -2707,7 +2907,7 @@ export class AmLyrics extends LitElement {
2707
2907
  alignment,
2708
2908
  songPart,
2709
2909
  translation: key ? translations[key] : undefined,
2710
- romanizedText: key ? transliterations[key] : undefined,
2910
+ romanizedText: lineTransliterationItem?.text,
2711
2911
  oppositeTurn: alignment === 'end',
2712
2912
  });
2713
2913
  }
@@ -2889,12 +3089,12 @@ export class AmLyrics extends LitElement {
2889
3089
  if (this.lyricsContainer) {
2890
3090
  this.lyricsContainer.addEventListener(
2891
3091
  'wheel',
2892
- this.handleUserScroll.bind(this),
3092
+ this._boundHandleUserScroll,
2893
3093
  { passive: true },
2894
3094
  );
2895
3095
  this.lyricsContainer.addEventListener(
2896
3096
  'touchmove',
2897
- this.handleUserScroll.bind(this),
3097
+ this._boundHandleUserScroll,
2898
3098
  { passive: true },
2899
3099
  );
2900
3100
  }
@@ -2908,15 +3108,15 @@ export class AmLyrics extends LitElement {
2908
3108
  */
2909
3109
  private _onTimeChanged(oldTime: number, newTime: number): void {
2910
3110
  const timeDiff = Math.abs(newTime - oldTime);
3111
+ const isSeek = timeDiff > SEEK_THRESHOLD_MS;
2911
3112
 
2912
3113
  const newActiveLines = this.findActiveLineIndices(newTime);
2913
3114
  const oldActiveLines = this.activeLineIndices;
2914
3115
 
2915
3116
  // Reset animation if active lines change or if we skip time.
2916
- // A threshold of 0.5s (500ms) is used to detect a "skip".
2917
3117
  const linesChanged = !AmLyrics.arraysEqual(newActiveLines, oldActiveLines);
2918
3118
 
2919
- if (linesChanged || timeDiff > 0.5) {
3119
+ if (linesChanged || isSeek) {
2920
3120
  // Imperatively manage 'active' class so that scroll-animate and other
2921
3121
  // imperative classes are never clobbered.
2922
3122
  if (this.lyricsContainer) {
@@ -2944,25 +3144,16 @@ export class AmLyrics extends LitElement {
2944
3144
  }
2945
3145
  }
2946
3146
  }
2947
- }
2948
- this.startAnimationFromTime(newTime);
2949
-
2950
- // Update position classes BEFORE scrolling so currentPrimaryActiveLine is current
2951
- if (this.lyricsContainer && this.activeLineIndices.length > 0) {
2952
- const primaryLineIndex = this.activeLineIndices[0];
2953
- const primaryLine = this.lyricsContainer.querySelector(
2954
- `#lyrics-line-${primaryLineIndex}`,
2955
- ) as HTMLElement;
2956
3147
 
2957
- if (primaryLine && primaryLine !== this.currentPrimaryActiveLine) {
2958
- this.lastPrimaryActiveLine = this.currentPrimaryActiveLine;
2959
- this.currentPrimaryActiveLine = primaryLine;
2960
- this.updatePositionClasses(primaryLine);
3148
+ if (newActiveLines.length > 0) {
3149
+ this.clearPreActiveClasses();
2961
3150
  }
2962
3151
  }
2963
3152
 
3153
+ this.startAnimationFromTime(newTime);
3154
+
2964
3155
  // Trigger scroll imperatively (was previously in updated() via @state)
2965
- this._handleActiveLineScroll(oldActiveLines);
3156
+ this._handleActiveLineScroll(oldActiveLines, isSeek);
2966
3157
  }
2967
3158
 
2968
3159
  // YouLyPlus-style syllable animation updates
@@ -2995,7 +3186,7 @@ export class AmLyrics extends LitElement {
2995
3186
  const isActive = gap.classList.contains('active');
2996
3187
  const isExiting = gap.classList.contains('gap-exiting');
2997
3188
  // Start exit animation early so it completes before the next lyric
2998
- const exitLeadMs = 600;
3189
+ const exitLeadMs = GAP_EXIT_LEAD_MS;
2999
3190
  const shouldStartExiting =
3000
3191
  isActive && !isExiting && newTime >= gapEndTime - exitLeadMs;
3001
3192
 
@@ -3023,16 +3214,6 @@ export class AmLyrics extends LitElement {
3023
3214
  AmLyrics.updateSyllableAnimation(dot as HTMLElement);
3024
3215
  }
3025
3216
  });
3026
-
3027
- // Scroll to the gap element so dots animate in with
3028
- // the staggered scroll rather than popping in.
3029
- if (
3030
- this.autoScroll &&
3031
- !this.isUserScrolling &&
3032
- !this.isClickSeeking
3033
- ) {
3034
- this.scrollToActiveLineYouLy(gap as HTMLElement);
3035
- }
3036
3217
  } else if (shouldStartExiting) {
3037
3218
  // Exiting gap: keep visible while dots animate out
3038
3219
  gap.classList.add('gap-exiting');
@@ -3040,7 +3221,7 @@ export class AmLyrics extends LitElement {
3040
3221
  // After exit animation completes, remove gap-exiting to collapse
3041
3222
  setTimeout(() => {
3042
3223
  gap.classList.remove('gap-exiting');
3043
- }, 800);
3224
+ }, GAP_EXIT_LEAD_MS);
3044
3225
  } else if (isActive && !shouldBeActive) {
3045
3226
  // NEW: Immediate cleanup if we seeked out of valid range
3046
3227
  gap.classList.remove('active');
@@ -3059,22 +3240,6 @@ export class AmLyrics extends LitElement {
3059
3240
  this.lastInstrumentalIndex = null;
3060
3241
  }
3061
3242
 
3062
- // Update position classes for YouLyPlus blur/opacity effect
3063
- // (only needed when lines didn't change — when they DID change,
3064
- // position classes are already updated above before scrolling)
3065
- if (!linesChanged && this.activeLineIndices.length > 0) {
3066
- const primaryLineIndex = this.activeLineIndices[0];
3067
- const primaryLine = this.lyricsContainer.querySelector(
3068
- `#lyrics-line-${primaryLineIndex}`,
3069
- ) as HTMLElement;
3070
-
3071
- if (primaryLine && primaryLine !== this.currentPrimaryActiveLine) {
3072
- this.lastPrimaryActiveLine = this.currentPrimaryActiveLine;
3073
- this.currentPrimaryActiveLine = primaryLine;
3074
- this.updatePositionClasses(primaryLine);
3075
- }
3076
- }
3077
-
3078
3243
  // Pre-scroll: scroll to upcoming line ~0.5s before it starts
3079
3244
  if (
3080
3245
  this.autoScroll &&
@@ -3082,11 +3247,11 @@ export class AmLyrics extends LitElement {
3082
3247
  !this.isClickSeeking &&
3083
3248
  this.lyrics
3084
3249
  ) {
3085
- const preScrollLeadMs = 500; // 500ms lead time
3086
-
3087
3250
  // Condition: ONLY pre-scroll if no other lyric is currently playing.
3088
3251
  // If a lyric is playing, we must wait for it to finish (handled by updated()).
3089
3252
  if (this.activeLineIndices.length === 0) {
3253
+ let preActiveLineIndex: number | null = null;
3254
+
3090
3255
  for (let i = 0; i < this.lyrics.length; i += 1) {
3091
3256
  const line = this.lyrics[i];
3092
3257
  const timeUntilStart = line.timestamp - newTime;
@@ -3095,23 +3260,20 @@ export class AmLyrics extends LitElement {
3095
3260
  `#lyrics-line-${i}`,
3096
3261
  ) as HTMLElement;
3097
3262
 
3098
- if (timeUntilStart > 0 && timeUntilStart <= preScrollLeadMs) {
3263
+ if (timeUntilStart > 0 && timeUntilStart <= PRE_SCROLL_LEAD_MS) {
3099
3264
  // Time to pre-scroll and pre-activate!
3100
3265
  if (nextLineEl) {
3101
3266
  // Apply unblur & zoom effect ahead of lyric start
3267
+ preActiveLineIndex = i;
3102
3268
  nextLineEl.classList.add('pre-active');
3103
-
3104
- // Only trigger scroll if we aren't already targeting this line
3105
- if (nextLineEl !== this.currentPrimaryActiveLine) {
3106
- this.scrollToActiveLineYouLy(nextLineEl);
3107
- }
3269
+ this.clearPreActiveClasses(i);
3270
+ this.focusLine(nextLineEl);
3108
3271
  }
3109
3272
  break;
3110
- } else if (nextLineEl) {
3111
- // Ensure lines outside the pre-scroll window don't stay pre-active
3112
- nextLineEl.classList.remove('pre-active');
3113
3273
  }
3114
3274
  }
3275
+
3276
+ this.clearPreActiveClasses(preActiveLineIndex);
3115
3277
  }
3116
3278
  }
3117
3279
  }
@@ -3192,40 +3354,25 @@ export class AmLyrics extends LitElement {
3192
3354
  * Handle scrolling when active line indices change.
3193
3355
  * Called imperatively from _onTimeChanged instead of from updated().
3194
3356
  */
3195
- private _handleActiveLineScroll(oldActiveIndices: number[]): void {
3196
- if (
3197
- !this.autoScroll ||
3198
- this.isUserScrolling ||
3199
- this.isClickSeeking ||
3200
- this.activeLineIndices.length === 0
3201
- ) {
3357
+ private _handleActiveLineScroll(
3358
+ _oldActiveIndices: number[],
3359
+ forceScroll = false,
3360
+ ): void {
3361
+ if (this.activeLineIndices.length === 0 || !this.lyricsContainer) {
3202
3362
  return;
3203
3363
  }
3204
3364
 
3205
- // Determine what changed: did we gain new lines or just lose old ones?
3206
- const newlyAdded = this.activeLineIndices.filter(
3207
- idx => !oldActiveIndices.includes(idx),
3365
+ const targetLineIndex = this.getPrimaryActiveLineIndex(
3366
+ this.activeLineIndices,
3208
3367
  );
3368
+ if (targetLineIndex === null) return;
3209
3369
 
3210
- if (newlyAdded.length === 0) {
3211
- // Only lost lines (an overlap resolved) — don't scroll
3212
- return;
3213
- }
3214
-
3215
- // New lines were added — scroll to the latest newly-added line.
3216
- // Previous overlap logic skipped every other line for songs with tiny
3217
- // timing overlaps between consecutive lines, causing a visible glitch.
3218
- const latestNewIndex = newlyAdded[newlyAdded.length - 1];
3219
- const targetLine = this.lyricsContainer?.querySelector(
3220
- `#lyrics-line-${latestNewIndex}`,
3370
+ const targetLine = this.lyricsContainer.querySelector(
3371
+ `#lyrics-line-${targetLineIndex}`,
3221
3372
  ) as HTMLElement;
3222
3373
 
3223
3374
  if (targetLine) {
3224
- this.scrollToActiveLineYouLy(targetLine);
3225
- } else if (this.currentPrimaryActiveLine) {
3226
- this.scrollToActiveLineYouLy(this.currentPrimaryActiveLine);
3227
- } else {
3228
- this.scrollToActiveLine();
3375
+ this.focusLine(targetLine, forceScroll);
3229
3376
  }
3230
3377
  }
3231
3378
 
@@ -3321,6 +3468,83 @@ export class AmLyrics extends LitElement {
3321
3468
  return a.length === b.length && a.every((val, i) => val === b[i]);
3322
3469
  }
3323
3470
 
3471
+ private static getLineIndexFromElement(
3472
+ lineElement: HTMLElement | null,
3473
+ ): number | null {
3474
+ if (!lineElement) return null;
3475
+ const match = lineElement.id.match(/^lyrics-line-(\d+)$/);
3476
+ return match ? parseInt(match[1], 10) : null;
3477
+ }
3478
+
3479
+ private static getGapLoopDelay(gapDuration: number): number {
3480
+ const desiredPhase = GAP_PULSE_DURATION_MS;
3481
+ const targetTime = gapDuration - GAP_EXIT_LEAD_MS;
3482
+ const normalizedTarget =
3483
+ ((targetTime % GAP_PULSE_CYCLE_MS) + GAP_PULSE_CYCLE_MS) %
3484
+ GAP_PULSE_CYCLE_MS;
3485
+
3486
+ return (
3487
+ (((desiredPhase - normalizedTarget) % GAP_PULSE_CYCLE_MS) +
3488
+ GAP_PULSE_CYCLE_MS) %
3489
+ GAP_PULSE_CYCLE_MS
3490
+ );
3491
+ }
3492
+
3493
+ private clearPreActiveClasses(exceptLineIndex: number | null = null): void {
3494
+ if (!this.lyricsContainer) return;
3495
+
3496
+ this.lyricsContainer
3497
+ .querySelectorAll('.lyrics-line.pre-active')
3498
+ .forEach(element => {
3499
+ const lineElement = element as HTMLElement;
3500
+ const lineIndex = AmLyrics.getLineIndexFromElement(lineElement);
3501
+ if (lineIndex !== exceptLineIndex) {
3502
+ lineElement.classList.remove('pre-active');
3503
+ }
3504
+ });
3505
+ }
3506
+
3507
+ private getPrimaryActiveLineIndex(activeIndices: number[]): number | null {
3508
+ if (activeIndices.length === 0) return null;
3509
+
3510
+ const groupStart = activeIndices[0];
3511
+ const groupEnd = activeIndices[activeIndices.length - 1];
3512
+ let candidateIndex = Math.max(groupStart, groupEnd - 2);
3513
+
3514
+ const currentPrimaryIndex = AmLyrics.getLineIndexFromElement(
3515
+ this.currentPrimaryActiveLine,
3516
+ );
3517
+ if (
3518
+ currentPrimaryIndex !== null &&
3519
+ activeIndices.includes(currentPrimaryIndex) &&
3520
+ candidateIndex < currentPrimaryIndex
3521
+ ) {
3522
+ candidateIndex = currentPrimaryIndex;
3523
+ }
3524
+
3525
+ return candidateIndex;
3526
+ }
3527
+
3528
+ private focusLine(lineElement: HTMLElement, forceScroll = false): void {
3529
+ const primaryChanged = lineElement !== this.currentPrimaryActiveLine;
3530
+
3531
+ if (primaryChanged) {
3532
+ this.lastPrimaryActiveLine = this.currentPrimaryActiveLine;
3533
+ this.currentPrimaryActiveLine = lineElement;
3534
+ }
3535
+
3536
+ this.updatePositionClasses(lineElement);
3537
+
3538
+ if (
3539
+ (forceScroll || primaryChanged) &&
3540
+ this.autoScroll &&
3541
+ !this.isUserScrolling &&
3542
+ !this.isClickSeeking
3543
+ ) {
3544
+ this.scrollToActiveLineYouLy(lineElement, forceScroll);
3545
+ }
3546
+ }
3547
+
3324
3548
  private handleUserScroll() {
3325
3549
  // Ignore programmatic scrolls and click-seek scrolls
3326
3550
  if (this.isProgrammaticScroll || this.isClickSeeking) {
@@ -3641,8 +3865,9 @@ export class AmLyrics extends LitElement {
3641
3865
  this.animatingLines = [];
3642
3866
 
3643
3867
  // Find the clicked line element and scroll to it with forceScroll (like YouLyPlus)
3868
+ // Timestamps are already in milliseconds — match the data-start-time attribute directly
3644
3869
  const clickedLineElement = this.lyricsContainer?.querySelector(
3645
- `.lyrics-line[data-start-time="${line.timestamp * 1000}"]`,
3870
+ `.lyrics-line[data-start-time="${line.text[0]?.timestamp || 0}"]`,
3646
3871
  ) as HTMLElement | null;
3647
3872
 
3648
3873
  if (clickedLineElement && this.lyricsContainer) {
@@ -3809,11 +4034,10 @@ export class AmLyrics extends LitElement {
3809
4034
  const { animatingLines } = this;
3810
4035
 
3811
4036
  const targetTop = Math.max(0, -newTranslateY);
3812
- // Always use actual scroll position - don't fall back to stale currentScrollOffset
3813
- // The || operator treats 0 as falsy, which caused bounce when scrollTop was 0
4037
+ const appliedTranslateY = -targetTop;
3814
4038
  const prevOffset = -parent.scrollTop;
3815
- const delta = prevOffset - newTranslateY;
3816
- this.currentScrollOffset = newTranslateY;
4039
+ const delta = prevOffset - appliedTranslateY;
4040
+ this.currentScrollOffset = appliedTranslateY;
3817
4041
 
3818
4042
  // Skip animation if already at the target position (e.g., first lines at top)
3819
4043
  if (Math.abs(parent.scrollTop - targetTop) < 1 && Math.abs(delta) < 1) {
@@ -3828,6 +4052,7 @@ export class AmLyrics extends LitElement {
3828
4052
  line.classList.remove('scroll-animate');
3829
4053
  line.style.removeProperty('--scroll-delta');
3830
4054
  line.style.removeProperty('--lyrics-line-delay');
4055
+ line.style.removeProperty('--scroll-duration');
3831
4056
  }
3832
4057
  animatingLines.length = 0;
3833
4058
  parent.scrollTo({ top: targetTop, behavior: 'smooth' });
@@ -3856,7 +4081,7 @@ export class AmLyrics extends LitElement {
3856
4081
  const referenceIndex = lineArray.indexOf(referenceLine);
3857
4082
  if (referenceIndex === -1) return;
3858
4083
 
3859
- const delayIncrement = 30;
4084
+ const delayIncrement = SCROLL_DELAY_INCREMENT_MS;
3860
4085
  const lookBehind = 10;
3861
4086
  const lookAhead = 15;
3862
4087
  const len = lineArray.length;
@@ -3878,10 +4103,14 @@ export class AmLyrics extends LitElement {
3878
4103
 
3879
4104
  line.style.setProperty('--scroll-delta', `${delta}px`);
3880
4105
  line.style.setProperty('--lyrics-line-delay', `${delay}ms`);
4106
+ line.style.setProperty(
4107
+ '--scroll-duration',
4108
+ `${SCROLL_ANIMATION_DURATION_MS}ms`,
4109
+ );
3881
4110
 
3882
4111
  newAnimatingLines.push(line);
3883
4112
 
3884
- const lineDuration = 400 + delay;
4113
+ const lineDuration = SCROLL_ANIMATION_DURATION_MS + delay;
3885
4114
  if (lineDuration > maxAnimationDuration) {
3886
4115
  maxAnimationDuration = lineDuration;
3887
4116
  }
@@ -3899,7 +4128,7 @@ export class AmLyrics extends LitElement {
3899
4128
  }
3900
4129
 
3901
4130
  animState.isAnimating = true;
3902
- const BASE_DURATION = 400;
4131
+ const BASE_DURATION = SCROLL_ANIMATION_DURATION_MS;
3903
4132
 
3904
4133
  this.scrollUnlockTimeout = setTimeout(() => {
3905
4134
  animState.isAnimating = false;
@@ -3917,6 +4146,7 @@ export class AmLyrics extends LitElement {
3917
4146
  line.classList.remove('scroll-animate');
3918
4147
  line.style.removeProperty('--scroll-delta');
3919
4148
  line.style.removeProperty('--lyrics-line-delay');
4149
+ line.style.removeProperty('--scroll-duration');
3920
4150
  }
3921
4151
  animatingLines.length = 0;
3922
4152
  this.scrollAnimationTimeout = undefined;
@@ -4022,7 +4252,7 @@ export class AmLyrics extends LitElement {
4022
4252
 
4023
4253
  setTimeout(() => {
4024
4254
  this.isProgrammaticScroll = false;
4025
- }, 600);
4255
+ }, SCROLL_ANIMATION_DURATION_MS + 160);
4026
4256
 
4027
4257
  this.animateScrollYouLy(targetTranslateY, forceScroll);
4028
4258
  }
@@ -4067,35 +4297,76 @@ export class AmLyrics extends LitElement {
4067
4297
  value: string;
4068
4298
  }> = [];
4069
4299
 
4070
- // Step 1: Grow Pass - apply grow-dynamic to ALL word chars on first syllable
4300
+ // Step 1 & 2: Apply animations
4071
4301
  if (isGrowable && isFirstSyllable && allWordCharSpans.length > 0) {
4072
- const finalDuration = wordDurationMs;
4073
- const baseDelayPerChar = finalDuration * 0.09;
4074
- const growDurationMs = finalDuration * 1.5;
4302
+ // Glow AND wipe applied to ALL characters simultaneously from the first syllable
4303
+ // This prevents CSS animation restarts because the `animation` property is set once.
4075
4304
 
4076
- allWordCharSpans.forEach(span => {
4305
+ const firstSyllableStartTime = parseFloat(
4306
+ syllable.getAttribute('data-start-time') || '0',
4307
+ );
4308
+
4309
+ allWordCharSpans.forEach((span, charIndexInWord) => {
4077
4310
  const horizontalOffset = parseFloat(
4078
4311
  span.dataset.horizontalOffset || '0',
4079
4312
  );
4080
- // Use syllableCharIndex like YouLyPlus, not loop index
4081
- const charIndex = parseFloat(span.dataset.syllableCharIndex || '0');
4082
- const growDelay = baseDelayPerChar * charIndex;
4083
4313
 
4084
- // READ DATA ATTRIBUTES for style values
4085
4314
  const maxScale = span.dataset.maxScale || '1.1';
4086
4315
  const shadowIntensity = span.dataset.shadowIntensity || '0.6';
4087
4316
  const translateYPeak = span.dataset.translateYPeak || '-2';
4088
4317
 
4089
- charAnimationsMap.set(
4090
- span,
4091
- `grow-dynamic ${growDurationMs}ms ease-in-out ${growDelay}ms forwards`,
4092
- );
4318
+ const animationParts: string[] = [];
4319
+
4320
+ const parentSyllable = span.closest('.lyrics-syllable');
4321
+ if (parentSyllable) {
4322
+ const parentDuration = parseFloat(
4323
+ parentSyllable.getAttribute('data-duration') || '0',
4324
+ );
4325
+ const parentStartTime = parseFloat(
4326
+ parentSyllable.getAttribute('data-start-time') || '0',
4327
+ );
4328
+
4329
+ const startPct = parseFloat(span.dataset.wipeStart || '0');
4330
+ const durationPct = parseFloat(span.dataset.wipeDuration || '0');
4331
+
4332
+ const relativeStartOffset = Math.max(
4333
+ 0,
4334
+ parentStartTime - firstSyllableStartTime,
4335
+ );
4336
+ const wipeDelay = relativeStartOffset + parentDuration * startPct;
4337
+ const wipeDuration = parentDuration * durationPct;
4338
+
4339
+ const useStartAnimation = isFirstInContainer && charIndexInWord === 0;
4340
+ let charWipeAnimation = 'wipe';
4341
+ if (useStartAnimation)
4342
+ charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
4343
+ else charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
4344
+
4345
+ // Blend word and syllable durations to let the gradient flow smoothly
4346
+ // while still responding to syllable pacing (no strict exactness, just natural flow)
4347
+ const growDelay = wipeDelay;
4348
+ const growDurationMs = Math.max(
4349
+ 600,
4350
+ wordDurationMs * 0.8 + parentDuration * 1.5,
4351
+ );
4352
+
4353
+ animationParts.push(
4354
+ `grow-dynamic ${growDurationMs}ms ease-in-out ${growDelay}ms forwards`,
4355
+ );
4356
+
4357
+ if (wipeDuration > 0) {
4358
+ animationParts.push(
4359
+ `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`,
4360
+ );
4361
+ }
4362
+ }
4363
+
4364
+ charAnimationsMap.set(span, animationParts.join(', '));
4093
4365
 
4094
- // Push style updates to be applied imperatively
4095
4366
  styleUpdates.push({
4096
4367
  element: span,
4097
4368
  property: '--char-offset-x',
4098
- value: `${horizontalOffset}`, // Fixed: removed px suitable for matrix3d
4369
+ value: `${horizontalOffset}`,
4099
4370
  });
4100
4371
  styleUpdates.push({
4101
4372
  element: span,
@@ -4110,14 +4381,34 @@ export class AmLyrics extends LitElement {
4110
4381
  styleUpdates.push({
4111
4382
  element: span,
4112
4383
  property: '--translate-y-peak',
4113
- value: `${translateYPeak}`, // Fixed: removed % because matrix3d expects raw number
4384
+ value: `${translateYPeak}`,
4114
4385
  });
4115
4386
  });
4116
- }
4387
+ } else if (isGrowable && !isFirstSyllable && charSpans.length > 0) {
4388
+ // For subsequent syllables of a growable word:
4389
+ // If they already have `grow-dynamic`, it means the first syllable correctly took care of BOTH grow and wipe!
4390
+ // Otherwise, they scrubbed directly into this syllable, so let's at least do the wipe.
4391
+ charSpans.forEach(span => {
4392
+ const existingAnimation =
4393
+ charAnimationsMap.get(span) || span.style.animation || '';
4394
+ if (existingAnimation.includes('grow-dynamic')) return;
4395
+
4396
+ const startPct = parseFloat(span.dataset.wipeStart || '0');
4397
+ const durationPct = parseFloat(span.dataset.wipeDuration || '0');
4398
+ const wipeDelay = syllableDurationMs * startPct;
4399
+ const wipeDuration = syllableDurationMs * durationPct;
4400
+
4401
+ const charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
4117
4402
 
4118
- // Step 2: Wipe Pass
4119
- if (charSpans.length > 0) {
4120
- // Per-character wipe for growable words (matching YouLyPlus)
4403
+ if (wipeDuration > 0) {
4404
+ charAnimationsMap.set(
4405
+ span,
4406
+ `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`,
4407
+ );
4408
+ }
4409
+ });
4410
+ } else if (charSpans.length > 0) {
4411
+ // Per-character wipe for non-growable words (matching YouLyPlus)
4121
4412
  charSpans.forEach((span, charIndex) => {
4122
4413
  const startPct = parseFloat(span.dataset.wipeStart || '0');
4123
4414
  const durationPct = parseFloat(span.dataset.wipeDuration || '0');
@@ -4126,54 +4417,39 @@ export class AmLyrics extends LitElement {
4126
4417
  const wipeDuration = syllableDurationMs * durationPct;
4127
4418
 
4128
4419
  const useStartAnimation = isFirstInContainer && charIndex === 0;
4129
- let charWipeAnimation: string;
4420
+ let charWipeAnimation = 'wipe';
4130
4421
  if (useStartAnimation) {
4131
4422
  charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
4132
4423
  } else {
4133
4424
  charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
4134
4425
  }
4135
4426
 
4136
- // Get existing animation from map (grow-dynamic) and combine with wipe
4137
- const existingAnimation =
4138
- charAnimationsMap.get(span) || span.style.animation || '';
4139
- const animationParts: string[] = [];
4140
-
4141
- if (existingAnimation && existingAnimation.includes('grow-dynamic')) {
4142
- animationParts.push(existingAnimation.split(',')[0].trim());
4143
- }
4144
-
4145
4427
  if (wipeDuration > 0) {
4146
- animationParts.push(
4428
+ charAnimationsMap.set(
4429
+ span,
4147
4430
  `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`,
4148
4431
  );
4149
4432
  }
4150
-
4151
- charAnimationsMap.set(span, animationParts.join(', '));
4152
4433
  });
4153
4434
  } else {
4154
- // Syllable-level wipe for regular (non-growable) words
4435
+ // Syllable-level wipe for regular (non-growable) words without chars
4155
4436
  const wipeRatio = parseFloat(
4156
4437
  syllable.getAttribute('data-wipe-ratio') || '1',
4157
4438
  );
4158
4439
  const visualDuration = syllableDurationMs * wipeRatio;
4159
4440
 
4160
- let wipeAnimation: string;
4441
+ let wipeAnimation = 'wipe';
4161
4442
  if (isFirstInContainer) {
4162
4443
  wipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
4163
4444
  } else {
4164
4445
  wipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
4165
4446
  }
4166
4447
 
4167
- if (syllable.classList.contains('line-synced')) {
4168
- // If line-synced, just add the class for CSS animation, or ensure valid state
4169
- // The CSS rule .lyrics-syllable.line-synced handles the fade
4170
- return;
4171
- }
4448
+ if (syllable.classList.contains('line-synced')) return;
4172
4449
 
4173
4450
  const currentWipeAnimation = isGap ? 'fade-gap' : wipeAnimation;
4174
- const syllableAnimation = `${currentWipeAnimation} ${visualDuration}ms ${isGap ? 'ease-out' : 'linear'} forwards`;
4175
4451
  // eslint-disable-next-line no-param-reassign
4176
- syllable.style.animation = syllableAnimation;
4452
+ syllable.style.animation = `${currentWipeAnimation} ${visualDuration}ms ${isGap ? 'ease-out' : 'linear'} forwards`;
4177
4453
  }
4178
4454
 
4179
4455
  // --- WRITE PHASE ---
@@ -4434,9 +4710,7 @@ export class AmLyrics extends LitElement {
4434
4710
  }
4435
4711
 
4436
4712
  if (running) {
4437
- this.animationFrameId = requestAnimationFrame(
4438
- this.animateProgress.bind(this),
4439
- );
4713
+ this.animationFrameId = requestAnimationFrame(this._boundAnimateProgress);
4440
4714
  } else if (this.animationFrameId) {
4441
4715
  // Stop animation if no words are running
4442
4716
  cancelAnimationFrame(this.animationFrameId);
@@ -4713,6 +4987,9 @@ export class AmLyrics extends LitElement {
4713
4987
  const groupGrowable: boolean[] = new Array(wordGroups.length).fill(
4714
4988
  false,
4715
4989
  );
4990
+ const groupGlowing: boolean[] = new Array(wordGroups.length).fill(
4991
+ false,
4992
+ );
4716
4993
  // Visual word info for growable char-level glow:
4717
4994
  // Each group stores the combined visual word's text, duration, and
4718
4995
  // the char offset of this group within the visual word.
@@ -4752,20 +5029,30 @@ export class AmLyrics extends LitElement {
4752
5029
  combinedText,
4753
5030
  );
4754
5031
  const hasHyphen = combinedText.includes('-');
4755
- const isGrowableVW =
4756
- !isCJK &&
4757
- !isRTL &&
4758
- !hasHyphen &&
4759
- combinedText.length <= 7 &&
4760
- combinedText.length > 0 &&
4761
- combinedDuration >= 900 &&
4762
- combinedDuration >= combinedText.length * 300 &&
4763
- (combinedText.length >= 4 ||
4764
- combinedDuration / combinedText.length >= 600);
5032
+
5033
+ const wordLen = combinedText.length;
5034
+ let isGrowableVW =
5035
+ !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 12;
5036
+ if (isGrowableVW) {
5037
+ if (wordLen < 3) {
5038
+ isGrowableVW =
5039
+ combinedDuration >= 1110 && combinedDuration >= wordLen * 550;
5040
+ } else {
5041
+ isGrowableVW =
5042
+ combinedDuration >= 800 && combinedDuration >= wordLen * 180;
5043
+ }
5044
+ }
5045
+
5046
+ // Glow requirement (more strict)
5047
+ const isGlowingVW =
5048
+ isGrowableVW &&
5049
+ combinedDuration >= 1200 &&
5050
+ combinedDuration >= combinedText.length * 300;
4765
5051
 
4766
5052
  let charOff = 0;
4767
5053
  for (let gi = vwStart; gi <= vwEnd; gi += 1) {
4768
5054
  groupGrowable[gi] = isGrowableVW;
5055
+ groupGlowing[gi] = isGlowingVW;
4769
5056
  vwFullText[gi] = combinedText;
4770
5057
  vwFullDuration[gi] = combinedDuration;
4771
5058
  vwCharOffset[gi] = charOff;
@@ -4783,264 +5070,159 @@ export class AmLyrics extends LitElement {
4783
5070
  const mainVocalElement = html`<p class="main-vocal-container">
4784
5071
  ${wordGroups.map((group, groupIdx) => {
4785
5072
  const isGrowable = groupGrowable[groupIdx];
5073
+ const isGlowing = groupGlowing[groupIdx];
5074
+ const groupLineSynced = group.some(s => s.lineSynced);
4786
5075
 
4787
- // For growable visual words spanning multiple groups:
4788
- // skip continuation groups (rendered by the first group)
4789
- if (isGrowable && vwCharOffset[groupIdx] > 0) {
4790
- return '';
4791
- }
5076
+ const wordText = isGrowable ? vwFullText[groupIdx] : '';
5077
+ const wordDuration = isGrowable ? vwFullDuration[groupIdx] : 0;
5078
+ const wordNumChars = wordText.length;
5079
+ const groupCharOffset = isGrowable ? vwCharOffset[groupIdx] : 0;
4792
5080
 
4793
- // Check if ANY syllable in group is line-synced
4794
- const groupLineSynced = group.some(s => s.lineSynced);
5081
+ let sylCharAccumulator = 0;
4795
5082
 
4796
- // For growable multi-group visual words, combine all text
4797
- // into one syllable so the wipe + glow animates as one unit
4798
- if (isGrowable && vwFullText[groupIdx].length > 0) {
4799
- const wordText = vwFullText[groupIdx];
4800
- const wordDuration = vwFullDuration[groupIdx];
4801
- const startTimeMs = vwStartMs[groupIdx];
4802
- const endTimeMs = vwEndMs[groupIdx];
4803
- const numChars = wordText.length;
4804
-
4805
- // Collect all text from groups in this visual word
4806
- // by scanning forward while vwCharOffset is consecutive
4807
- let combinedRawText = '';
4808
- for (let gi = groupIdx; gi < wordGroups.length; gi += 1) {
4809
- if (gi > groupIdx && vwCharOffset[gi] === 0) break;
4810
- if (gi > groupIdx && !groupGrowable[gi]) break;
4811
- combinedRawText += wordGroups[gi].map(s => s.text).join('');
4812
- }
5083
+ return html`<span
5084
+ class="lyrics-word ${isGrowable ? 'growable' : ''} ${isGlowing
5085
+ ? 'glowing'
5086
+ : ''} ${group.length > 1 ? 'allow-break' : ''}"
5087
+ >
5088
+ ${group.map((syllable, sylIdx) => {
5089
+ const startTimeMs = syllable.timestamp;
5090
+ const endTimeMs = syllable.endtime;
5091
+ const durationMs = endTimeMs - startTimeMs;
5092
+ const text = syllable.text || '';
4813
5093
 
4814
- const syllableContent = html`${combinedRawText
4815
- .split('')
4816
- .map((char, charIndex) => {
4817
- if (char === ' ') {
4818
- return ' ';
4819
- }
4820
- const charStartPercent = charIndex / numChars;
4821
-
4822
- const minDuration = 1000;
4823
- const maxDuration = 5000;
4824
- const easingPower = 3;
4825
- const progress = Math.min(
4826
- 1,
4827
- Math.max(
4828
- 0,
4829
- (wordDuration - minDuration) /
4830
- (maxDuration - minDuration),
4831
- ),
4832
- );
4833
- const easedProgress = progress ** easingPower;
4834
-
4835
- const isLongWord = numChars > 5;
4836
- const isShortDuration = wordDuration < 1500;
4837
- let maxDecayRate = 0;
4838
- if (isLongWord || isShortDuration) {
4839
- let decayStrength = 0;
4840
- if (isLongWord)
4841
- decayStrength += Math.min((numChars - 5) / 3, 1.0) * 0.4;
4842
- if (isShortDuration)
4843
- decayStrength +=
4844
- Math.max(0, 1.0 - (wordDuration - 1000) / 500) * 0.4;
4845
- maxDecayRate = Math.min(decayStrength, 0.85);
4846
- }
4847
-
4848
- const positionInWord =
4849
- numChars > 1 ? charIndex / (numChars - 1) : 0;
4850
- const decayFactor = 1.0 - positionInWord * maxDecayRate;
4851
- const charProgress = easedProgress * decayFactor;
4852
-
4853
- const baseGrowth = numChars <= 3 ? 0.07 : 0.05;
4854
- const charMaxScale = 1.0 + baseGrowth + charProgress * 0.1;
4855
- const charShadowIntensity = 0.4 + charProgress * 0.4;
4856
- const normalizedGrowth = (charMaxScale - 1.0) / 0.13;
4857
- const charTranslateYPeak = -normalizedGrowth * 6;
4858
-
4859
- const position = (charIndex + 0.5) / numChars;
4860
- const horizontalOffset =
4861
- (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25);
4862
-
4863
- return html`<span
4864
- class="char"
4865
- data-char-index="${charIndex}"
4866
- data-syllable-char-index="${charIndex}"
4867
- data-wipe-start="${charStartPercent.toFixed(4)}"
4868
- data-wipe-duration="${(1 / numChars).toFixed(4)}"
4869
- data-horizontal-offset="${horizontalOffset.toFixed(2)}"
4870
- data-max-scale="${charMaxScale.toFixed(3)}"
4871
- data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
4872
- data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
4873
- >${char}</span
4874
- >`;
4875
- })}`;
4876
-
4877
- return html`<span class="lyrics-word growable">
4878
- <span class="lyrics-syllable-wrap">
4879
- <span
4880
- class="lyrics-syllable ${groupLineSynced
4881
- ? 'line-synced'
4882
- : ''}"
4883
- data-start-time="${startTimeMs}"
4884
- data-end-time="${endTimeMs}"
4885
- data-duration="${wordDuration}"
4886
- data-syllable-index="0"
4887
- data-wipe-ratio="1"
4888
- >${syllableContent}</span
4889
- >
4890
- </span>
4891
- </span>`;
4892
- }
5094
+ const romanizedText =
5095
+ this.showRomanization &&
5096
+ syllable.romanizedText &&
5097
+ syllable.romanizedText.trim() !== syllable.text.trim()
5098
+ ? html`<span
5099
+ class="lyrics-syllable transliteration ${groupLineSynced
5100
+ ? 'line-synced'
5101
+ : ''}"
5102
+ data-start-time="${startTimeMs}"
5103
+ data-end-time="${endTimeMs}"
5104
+ data-duration="${durationMs}"
5105
+ data-syllable-index="0"
5106
+ data-wipe-ratio="1"
5107
+ >${syllable.romanizedText}</span
5108
+ >`
5109
+ : '';
4893
5110
 
4894
- // For single-syllable groups, use original logic
4895
- if (group.length === 1) {
4896
- const syllable = group[0];
4897
- const startTimeMs = syllable.timestamp;
4898
- const endTimeMs = syllable.endtime;
4899
- const durationMs = endTimeMs - startTimeMs;
4900
- const text = syllable.text || '';
4901
- const trimmedText = text.trim();
4902
-
4903
- // Optional romanization per syllable (hide if same as the original text)
4904
- const romanizedText =
4905
- this.showRomanization &&
4906
- syllable.romanizedText &&
4907
- syllable.romanizedText.trim() !== syllable.text.trim()
4908
- ? html`<span
4909
- class="lyrics-syllable transliteration ${syllable.lineSynced
4910
- ? 'line-synced'
4911
- : ''}"
4912
- data-start-time="${startTimeMs}"
4913
- data-end-time="${endTimeMs}"
4914
- data-duration="${durationMs}"
4915
- data-syllable-index="0"
4916
- data-wipe-ratio="1"
4917
- >${syllable.romanizedText}</span
4918
- >`
4919
- : '';
4920
-
4921
- // For growable words (single-group visual word), use char glow
4922
- const syllableContent = isGrowable
4923
- ? html`${text.split('').map((char, charIndex) => {
4924
- if (char === ' ') {
4925
- return ' ';
4926
- }
4927
- const numChars = trimmedText.length;
4928
- const charStartPercent = charIndex / text.length;
5111
+ let syllableContent: any = text;
4929
5112
 
4930
- const minDuration = 1000;
4931
- const maxDuration = 5000;
5113
+ if (isGrowable) {
5114
+ let charIndexInsideSyllable = 0;
5115
+ const numCharsInSyllable =
5116
+ text.replace(/\s/g, '').length || 1;
5117
+
5118
+ syllableContent = html`${text.split('').map(char => {
5119
+ if (char === ' ') return ' ';
5120
+
5121
+ const charIndexInsideWord =
5122
+ groupCharOffset + sylCharAccumulator;
5123
+ const charStartPercentVal =
5124
+ charIndexInsideSyllable / numCharsInSyllable;
5125
+
5126
+ sylCharAccumulator += 1;
5127
+ charIndexInsideSyllable += 1;
5128
+
5129
+ const minDuration = 400;
5130
+ const maxDuration = 3000;
4932
5131
  const easingPower = 3;
4933
5132
  const progress = Math.min(
4934
5133
  1,
4935
5134
  Math.max(
4936
5135
  0,
4937
- (durationMs - minDuration) /
5136
+ (wordDuration - minDuration) /
4938
5137
  (maxDuration - minDuration),
4939
5138
  ),
4940
5139
  );
4941
5140
  const easedProgress = progress ** easingPower;
4942
5141
 
4943
- const isLongWord = numChars > 5;
4944
- const isShortDuration = durationMs < 1500;
5142
+ const isLongWord = wordNumChars > 5;
5143
+ const isShortDuration = wordDuration < 1200;
4945
5144
  let maxDecayRate = 0;
4946
5145
  if (isLongWord || isShortDuration) {
4947
5146
  let decayStrength = 0;
4948
5147
  if (isLongWord)
4949
5148
  decayStrength +=
4950
- Math.min((numChars - 5) / 3, 1.0) * 0.4;
4951
- if (isShortDuration)
5149
+ Math.min((wordNumChars - 5) / 5, 1.0) * 0.4;
5150
+ if (isShortDuration && wordNumChars > 3)
5151
+ decayStrength +=
5152
+ Math.max(0, 1.0 - (wordDuration - 800) / 400) * 0.3;
5153
+ else if (isShortDuration && wordNumChars <= 3)
4952
5154
  decayStrength +=
4953
- Math.max(0, 1.0 - (durationMs - 1000) / 500) * 0.4;
4954
- maxDecayRate = Math.min(decayStrength, 0.85);
5155
+ Math.max(0, 1.0 - (wordDuration - 800) / 400) * 0.1;
5156
+ maxDecayRate = Math.min(decayStrength, 0.7);
4955
5157
  }
4956
5158
 
4957
5159
  const positionInWord =
4958
- numChars > 1 ? charIndex / (numChars - 1) : 0;
5160
+ wordNumChars > 1
5161
+ ? charIndexInsideWord / (wordNumChars - 1)
5162
+ : 0;
4959
5163
  const decayFactor = 1.0 - positionInWord * maxDecayRate;
4960
5164
  const charProgress = easedProgress * decayFactor;
4961
5165
 
4962
- const baseGrowth = numChars <= 3 ? 0.07 : 0.05;
4963
- const charMaxScale = 1.0 + baseGrowth + charProgress * 0.1;
4964
- const charShadowIntensity = 0.4 + charProgress * 0.4;
4965
- const normalizedGrowth = (charMaxScale - 1.0) / 0.13;
4966
- const charTranslateYPeak = -normalizedGrowth * 6;
5166
+ const baseGrowth = wordNumChars <= 3 ? 0.05 : 0.04;
5167
+ const charMaxScale = 1.0 + baseGrowth + charProgress * 0.08;
5168
+ const glowDurFactor = Math.min(1.1, wordDuration / 1500);
5169
+ let glowLenFactor = 1.0;
5170
+ if (wordNumChars <= 3) {
5171
+ glowLenFactor = 0.85;
5172
+ } else if (wordNumChars >= 6) {
5173
+ glowLenFactor = 1.1;
5174
+ }
5175
+ const glowIntensityScale = glowDurFactor * glowLenFactor;
5176
+ const charShadowIntensity = isGlowing
5177
+ ? (0.35 + charProgress * 0.45) * glowIntensityScale
5178
+ : 0;
5179
+ const normalizedGrowth = (charMaxScale - 1.0) / 0.1;
5180
+ const effectiveDuration =
5181
+ (wordDuration + durationMs * 2) / 3;
5182
+ const peakMultiplier = Math.min(
5183
+ 1,
5184
+ Math.max(0.3, effectiveDuration / 2000),
5185
+ );
5186
+ const charTranslateYPeak =
5187
+ -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
4967
5188
 
4968
- const position = (charIndex + 0.5) / numChars;
5189
+ const position = (charIndexInsideWord + 0.5) / wordNumChars;
4969
5190
  const horizontalOffset =
4970
5191
  (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25);
4971
5192
 
4972
5193
  return html`<span
4973
5194
  class="char"
4974
- data-char-index="${charIndex}"
4975
- data-syllable-char-index="${charIndex}"
4976
- data-wipe-start="${charStartPercent.toFixed(4)}"
4977
- data-wipe-duration="${(1 / text.length).toFixed(4)}"
5195
+ data-char-index="${charIndexInsideWord}"
5196
+ data-syllable-char-index="${charIndexInsideWord}"
5197
+ data-wipe-start="${charStartPercentVal.toFixed(4)}"
5198
+ data-wipe-duration="${(1 / numCharsInSyllable).toFixed(
5199
+ 4,
5200
+ )}"
4978
5201
  data-horizontal-offset="${horizontalOffset.toFixed(2)}"
4979
5202
  data-max-scale="${charMaxScale.toFixed(3)}"
4980
5203
  data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
4981
5204
  data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
4982
5205
  >${char}</span
4983
5206
  >`;
4984
- })}`
4985
- : text;
5207
+ })}`;
5208
+ }
4986
5209
 
4987
- return html`<span
4988
- class="lyrics-word ${isGrowable ? 'growable' : ''}"
4989
- >
4990
- <span class="lyrics-syllable-wrap">
5210
+ return html`<span class="lyrics-syllable-wrap">
4991
5211
  <span
4992
- class="lyrics-syllable ${syllable.lineSynced
5212
+ class="lyrics-syllable ${groupLineSynced
4993
5213
  ? 'line-synced'
4994
5214
  : ''}"
4995
5215
  data-start-time="${startTimeMs}"
4996
5216
  data-end-time="${endTimeMs}"
4997
5217
  data-duration="${durationMs}"
4998
- data-syllable-index="0"
5218
+ data-word-duration="${wordDuration}"
5219
+ data-syllable-index="${sylIdx}"
4999
5220
  data-wipe-ratio="1"
5000
5221
  >${syllableContent}</span
5001
5222
  >
5002
5223
  ${romanizedText}
5003
- </span>
5004
- </span>`;
5005
- }
5006
-
5007
- // Multi-syllable group (part=true): render all syllables inside one lyrics-word
5008
- return html`<span
5009
- class="lyrics-word ${isGrowable ? 'growable' : ''} allow-break"
5010
- >
5011
- ${group.map(
5012
- (syllable, sylIdx) => html`
5013
- <span class="lyrics-syllable-wrap">
5014
- <span
5015
- class="lyrics-syllable ${groupLineSynced
5016
- ? 'line-synced'
5017
- : ''}"
5018
- data-start-time="${syllable.timestamp}"
5019
- data-end-time="${syllable.endtime}"
5020
- data-duration="${syllable.endtime - syllable.timestamp}"
5021
- data-syllable-index="${sylIdx}"
5022
- data-wipe-ratio="1"
5023
- >${syllable.text}</span
5024
- >
5025
- ${this.showRomanization &&
5026
- syllable.romanizedText &&
5027
- syllable.romanizedText.trim() !== syllable.text.trim()
5028
- ? html`<span
5029
- class="lyrics-syllable transliteration ${groupLineSynced
5030
- ? 'line-synced'
5031
- : ''}"
5032
- data-start-time="${syllable.timestamp}"
5033
- data-end-time="${syllable.endtime}"
5034
- data-duration="${syllable.endtime -
5035
- syllable.timestamp}"
5036
- data-syllable-index="0"
5037
- data-wipe-ratio="1"
5038
- >${syllable.romanizedText}</span
5039
- >`
5040
- : ''}
5041
- </span>
5042
- `,
5043
- )}
5224
+ </span>`;
5225
+ })}
5044
5226
  </span>`;
5045
5227
  })}
5046
5228
  </p>`;
@@ -5076,8 +5258,10 @@ export class AmLyrics extends LitElement {
5076
5258
  let maybeInstrumentalBlock: unknown = null;
5077
5259
  const gapForLine = gapByIndex.get(lineIndex);
5078
5260
  if (gapForLine) {
5261
+ const gapDuration = gapForLine.gapEnd - gapForLine.gapStart;
5079
5262
  // Calculate dot timing for fill-up animation (3 dots)
5080
- const dotDuration = (gapForLine.gapEnd - gapForLine.gapStart) / 3;
5263
+ const dotDuration = gapDuration / 3;
5264
+ const gapLoopDelay = AmLyrics.getGapLoopDelay(gapDuration);
5081
5265
 
5082
5266
  // Gap starts without 'active' — _onTimeChanged toggles it imperatively
5083
5267
  maybeInstrumentalBlock = html`<div
@@ -5085,6 +5269,7 @@ export class AmLyrics extends LitElement {
5085
5269
  class="lyrics-line lyrics-gap"
5086
5270
  data-start-time="${gapForLine.gapStart}"
5087
5271
  data-end-time="${gapForLine.gapEnd}"
5272
+ style="--gap-pulse-duration: ${GAP_PULSE_DURATION_MS}ms; --gap-loop-delay: -${gapLoopDelay}ms; --gap-exit-duration: ${GAP_EXIT_LEAD_MS}ms; --gap-exit-scale: ${GAP_MIN_SCALE};"
5088
5273
  >
5089
5274
  <div class="lyrics-line-container">
5090
5275
  <p class="main-vocal-container">