@uimaxbai/am-lyrics 1.4.1 → 1.5.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,14 +2,11 @@ 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.4.1';
5
+ const VERSION = '1.5.0';
6
6
  const INSTRUMENTAL_THRESHOLD_MS = 7000; // Show dots for gaps >= 7s
7
7
  const FETCH_TIMEOUT_MS = 8000; // Timeout for all lyrics fetch requests
8
8
  const SEEK_THRESHOLD_MS = 500;
9
- const PRE_SCROLL_LEAD_MS = 500;
10
- const PRE_SCROLL_LEAD_SHORT_MS = 350;
11
- const SCROLL_ANIMATION_DURATION_MS = 280;
12
- const SCROLL_DELAY_INCREMENT_MS = 24;
9
+ const SCROLL_ANIMATION_DURATION_MS = 350;
13
10
  const GAP_PULSE_DURATION_MS = 4000;
14
11
  const GAP_PULSE_CYCLE_MS = GAP_PULSE_DURATION_MS * 2;
15
12
  const GAP_EXIT_LEAD_MS = 600;
@@ -168,6 +165,7 @@ export class AmLyrics extends LitElement {
168
165
  -webkit-overflow-scrolling: touch;
169
166
  box-sizing: border-box;
170
167
  scrollbar-width: none;
168
+ overflow-anchor: none;
171
169
  }
172
170
 
173
171
  .lyrics-container::-webkit-scrollbar {
@@ -188,9 +186,16 @@ export class AmLyrics extends LitElement {
188
186
  }
189
187
 
190
188
  .lyrics-line.scroll-animate {
191
- transition: none !important; /* Prevent conflict with scroll animation */
189
+ /* Preserve the graceful fade duration; the keyframe handles the
190
+ transform, so we only need to keep opacity/filter transitions
191
+ alive without !important overriding the base rule. */
192
+ transition:
193
+ opacity 0.7s ease,
194
+ filter 0.7s ease,
195
+ transform 0.4s cubic-bezier(0.41, 0, 0.12, 0.99)
196
+ var(--lyrics-line-delay, 0ms);
192
197
  animation-name: lyrics-scroll;
193
- animation-duration: var(--scroll-duration, 280ms);
198
+ animation-duration: var(--scroll-duration, 400ms);
194
199
  animation-timing-function: cubic-bezier(0.41, 0, 0.12, 0.99);
195
200
  animation-fill-mode: both;
196
201
  animation-delay: var(--lyrics-line-delay, 0ms);
@@ -211,12 +216,15 @@ export class AmLyrics extends LitElement {
211
216
  font-size: var(--lyplus-font-size-base);
212
217
  cursor: pointer;
213
218
  transform-origin: left;
219
+ /* Graceful 0.7 s fade so the line stays mostly bright while the
220
+ 0.4 s scroll animation runs, then settles into the inactive state. */
214
221
  transition:
215
- opacity 0.3s ease,
222
+ opacity 0.7s ease,
216
223
  transform 0.4s cubic-bezier(0.41, 0, 0.12, 0.99)
217
224
  var(--lyrics-line-delay, 0ms),
218
- filter 0.3s ease;
225
+ filter 0.7s ease;
219
226
  content-visibility: auto;
227
+ contain: layout style;
220
228
  text-rendering: optimizeLegibility;
221
229
  }
222
230
 
@@ -228,7 +236,7 @@ export class AmLyrics extends LitElement {
228
236
  .lyrics-line-container {
229
237
  overflow-wrap: break-word;
230
238
  transform-origin: left;
231
- transform: scale3d(0.93, 0.93, 0.95);
239
+ transform: translateZ(0);
232
240
  transition:
233
241
  transform 0.7s ease,
234
242
  background-color 0.7s,
@@ -237,7 +245,7 @@ export class AmLyrics extends LitElement {
237
245
 
238
246
  .lyrics-line.active .lyrics-line-container,
239
247
  .lyrics-line.pre-active .lyrics-line-container {
240
- transform: scale3d(1.001, 1.001, 1) translateZ(0);
248
+ transform: translateZ(0);
241
249
  transition:
242
250
  transform 0.5s ease,
243
251
  background-color 0.18s,
@@ -251,31 +259,50 @@ export class AmLyrics extends LitElement {
251
259
 
252
260
  .background-vocal-container {
253
261
  max-height: 0;
254
- padding-top: 0;
255
- transform: translateY(-0.5em) scale(0.95);
256
262
  overflow: visible;
257
263
  opacity: 0;
258
264
  font-size: var(--lyplus-font-size-subtext);
265
+ line-height: 1.15;
266
+ color: color-mix(in srgb, var(--lyplus-text-secondary) 80%, transparent);
267
+ /* Fast exit (0.25 s) so bg vocals collapse quickly and feel snappy.
268
+ The scroll takes ~0.4 s; finishing the collapse first prevents
269
+ the container from trailing behind the motion. */
259
270
  transition:
260
- max-height 450ms cubic-bezier(0.33, 1, 0.68, 1),
261
- opacity 400ms ease-out,
262
- transform 450ms cubic-bezier(0.33, 1, 0.68, 1),
263
- padding 450ms cubic-bezier(0.33, 1, 0.68, 1);
271
+ max-height 250ms cubic-bezier(0.41, 0, 0.12, 0.99),
272
+ opacity 250ms cubic-bezier(0.41, 0, 0.12, 0.99);
264
273
  margin: 0;
274
+ pointer-events: none;
275
+ }
276
+
277
+ .background-vocal-wrap {
278
+ display: block;
279
+ padding-top: 0;
280
+ padding-bottom: 0;
281
+ transition: padding-top 250ms cubic-bezier(0.41, 0, 0.12, 0.99);
282
+ }
283
+
284
+ .lyrics-line.singer-right .background-vocal-container,
285
+ .lyrics-line.rtl-text .background-vocal-container {
286
+ margin-left: auto;
287
+ margin-right: 0;
265
288
  }
266
289
 
267
- .lyrics-line.active .background-vocal-container,
268
- .lyrics-line.pre-active .background-vocal-container {
290
+ /* Background vocals expand only when .bg-expanded is present.
291
+ This is separate from .active so bg vocals can collapse immediately
292
+ while .active stays to keep text white until the scroll passes. */
293
+ .lyrics-line.bg-expanded .background-vocal-container {
269
294
  max-height: 4em;
270
295
  opacity: 1;
271
- padding-top: 0.2em;
272
- transform: translateY(0) scale(1);
296
+ /* Slower entry (0.6 s) so bg vocals expand smoothly. */
273
297
  transition:
274
- max-height 450ms cubic-bezier(0.22, 1, 0.36, 1),
275
- opacity 400ms ease-out,
276
- transform 450ms cubic-bezier(0.22, 1, 0.36, 1),
277
- padding 450ms cubic-bezier(0.22, 1, 0.36, 1);
278
- will-change: max-height, opacity, padding, transform;
298
+ max-height 0.6s ease,
299
+ opacity 0.6s ease;
300
+ will-change: max-height, opacity;
301
+ }
302
+
303
+ .lyrics-line.bg-expanded .background-vocal-wrap {
304
+ padding-top: 0.26em;
305
+ transition: padding-top 0.6s ease;
279
306
  }
280
307
 
281
308
  /* --- Line States & Modifiers --- */
@@ -288,6 +315,16 @@ export class AmLyrics extends LitElement {
288
315
  opacity: 1;
289
316
  }
290
317
 
318
+ .lyrics-line.persist-highlight {
319
+ filter: none !important;
320
+ opacity: 1;
321
+ }
322
+
323
+ .lyrics-line.persist-highlight .lyrics-syllable.finished,
324
+ .lyrics-line.persist-highlight .lyrics-syllable.finished span.char {
325
+ transition: none !important;
326
+ }
327
+
291
328
  .lyrics-line.singer-right {
292
329
  text-align: end;
293
330
  }
@@ -352,22 +389,32 @@ export class AmLyrics extends LitElement {
352
389
 
353
390
  /* --- Blur Effect for Inactive Lines --- */
354
391
  .lyrics-container.blur-inactive-enabled:not(.not-focused)
355
- .lyrics-line:not(.active):not(.pre-active):not(.lyrics-gap) {
392
+ .lyrics-line:not(.active):not(.pre-active):not(.lyrics-gap):not(
393
+ .persist-highlight
394
+ ) {
356
395
  filter: blur(var(--lyplus-blur-amount));
357
396
  }
358
397
 
398
+ /* Viewport Virtualization: Strip expensive filters and animations from
399
+ offscreen lines. IntersectionObserver toggles this class. */
400
+ .lyrics-line.far-line {
401
+ filter: none !important;
402
+ will-change: auto !important;
403
+ animation: none !important;
404
+ }
405
+
359
406
  .lyrics-container.blur-inactive-enabled:not(.not-focused)
360
407
  .lyrics-line.post-active-line:not(.lyrics-gap):not(.active):not(
361
408
  .pre-active
362
- ),
409
+ ):not(.persist-highlight),
363
410
  .lyrics-container.blur-inactive-enabled:not(.not-focused)
364
411
  .lyrics-line.next-active-line:not(.lyrics-gap):not(.active):not(
365
412
  .pre-active
366
- ),
413
+ ):not(.persist-highlight),
367
414
  .lyrics-container.blur-inactive-enabled:not(.not-focused)
368
415
  .lyrics-line.lyrics-activest:not(.active):not(.lyrics-gap):not(
369
416
  .pre-active
370
- ) {
417
+ ):not(.persist-highlight) {
371
418
  filter: blur(var(--lyplus-blur-amount-near));
372
419
  }
373
420
 
@@ -397,6 +444,17 @@ export class AmLyrics extends LitElement {
397
444
  display: inline;
398
445
  }
399
446
 
447
+ .lyrics-word.char-rise {
448
+ display: inline-block;
449
+ vertical-align: baseline;
450
+ white-space: nowrap;
451
+ }
452
+
453
+ .lyrics-word.char-rise.allow-break {
454
+ display: inline;
455
+ white-space: normal;
456
+ }
457
+
400
458
  .lyrics-syllable-wrap {
401
459
  display: inline;
402
460
  }
@@ -426,17 +484,19 @@ export class AmLyrics extends LitElement {
426
484
  /* --- Syllable States --- */
427
485
  .lyrics-syllable.finished {
428
486
  background-color: var(--lyplus-text-primary);
429
- transition: transform 1s ease !important;
487
+ /* Unified transition: transform keeps its 1s glow decay, while
488
+ background-color and color fade at 0.7s so everything dims
489
+ together when the line becomes inactive. */
490
+ transition:
491
+ transform 1s ease,
492
+ background-color 0.7s ease,
493
+ color 0.7s ease;
430
494
  }
431
495
 
432
496
  .lyrics-syllable.finished.has-chars {
433
497
  background-color: transparent;
434
498
  }
435
499
 
436
- .lyrics-line:not(.active) .lyrics-syllable.finished {
437
- transition: color 0.18s;
438
- }
439
-
440
500
  .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable {
441
501
  transition:
442
502
  transform 1s ease,
@@ -486,7 +546,60 @@ export class AmLyrics extends LitElement {
486
546
  );
487
547
  background-position:
488
548
  calc(100% + 0.5em) 0%,
489
- right;
549
+ right 0%;
550
+ }
551
+
552
+ /* Background vocals: muted gray wipe instead of white.
553
+ Must match specificity of the main .active .highlight rule (0,3,1). */
554
+ .lyrics-line.active
555
+ .background-vocal-container
556
+ .lyrics-syllable.highlight.no-chars,
557
+ .lyrics-line.active
558
+ .background-vocal-container
559
+ .lyrics-syllable.pre-highlight.no-chars,
560
+ .lyrics-line.pre-active
561
+ .background-vocal-container
562
+ .lyrics-syllable.highlight.no-chars,
563
+ .lyrics-line.pre-active
564
+ .background-vocal-container
565
+ .lyrics-syllable.pre-highlight.no-chars {
566
+ background-image:
567
+ linear-gradient(
568
+ 90deg,
569
+ #ffffff00 0%,
570
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 50%,
571
+ #0000 100%
572
+ ),
573
+ linear-gradient(
574
+ 90deg,
575
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 100%,
576
+ #0000 100%
577
+ );
578
+ }
579
+
580
+ .lyrics-line.active
581
+ .background-vocal-container
582
+ .lyrics-syllable.highlight.rtl-text,
583
+ .lyrics-line.active
584
+ .background-vocal-container
585
+ .lyrics-syllable.pre-highlight.rtl-text,
586
+ .lyrics-line.pre-active
587
+ .background-vocal-container
588
+ .lyrics-syllable.highlight.rtl-text,
589
+ .lyrics-line.pre-active
590
+ .background-vocal-container
591
+ .lyrics-syllable.pre-highlight.rtl-text {
592
+ background-image:
593
+ linear-gradient(
594
+ -90deg,
595
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 0%,
596
+ transparent 100%
597
+ ),
598
+ linear-gradient(
599
+ -90deg,
600
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 100%,
601
+ transparent 100%
602
+ );
490
603
  }
491
604
 
492
605
  /* Non-growable words float up with a gentle curve */
@@ -494,18 +607,88 @@ export class AmLyrics extends LitElement {
494
607
  .lyrics-word:not(.growable)
495
608
  .lyrics-syllable.highlight {
496
609
  transform: translateY(-3.5%);
497
- transition:
498
- transform var(--rise-duration, 1.5s) cubic-bezier(0.22, 1, 0.36, 1),
499
- background-color 0.5s,
500
- color 0.5s;
610
+ }
611
+
612
+ .lyrics-line.persist-highlight:not(.lyrics-gap)
613
+ .lyrics-word:not(.growable)
614
+ .lyrics-syllable.finished {
615
+ transform: translateY(-3.5%);
501
616
  }
502
617
 
503
618
  .lyrics-word.growable .lyrics-syllable.cleanup .char {
504
619
  transform: translateY(-3.5%);
505
620
  }
506
621
 
507
- .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.highlight.finished {
508
- background-image: none;
622
+ .lyrics-word.char-rise .lyrics-syllable.cleanup .char {
623
+ transform: translateY(-3.5%);
624
+ }
625
+
626
+ .lyrics-line.persist-highlight
627
+ .lyrics-word.growable
628
+ .lyrics-syllable.finished
629
+ .char,
630
+ .lyrics-line.persist-highlight
631
+ .lyrics-word.char-rise
632
+ .lyrics-syllable.finished
633
+ .char {
634
+ transform: translateY(-3.5%);
635
+ }
636
+
637
+ /* Background vocal overrides — placed AFTER main rules so they win
638
+ on equal specificity. */
639
+ .background-vocal-container .lyrics-syllable {
640
+ background-color: color-mix(
641
+ in srgb,
642
+ var(--lyplus-text-secondary) 50%,
643
+ #888888
644
+ );
645
+ }
646
+
647
+ .lyrics-line.active:not(.lyrics-gap)
648
+ .background-vocal-container
649
+ .lyrics-syllable.finished,
650
+ .lyrics-line.pre-active
651
+ .background-vocal-container
652
+ .lyrics-syllable.finished {
653
+ background-color: color-mix(
654
+ in srgb,
655
+ var(--lyplus-text-primary) 50%,
656
+ #888888
657
+ );
658
+ }
659
+
660
+ .background-vocal-container .lyrics-syllable.line-synced {
661
+ color: color-mix(
662
+ in srgb,
663
+ var(--lyplus-text-secondary) 50%,
664
+ #888888
665
+ ) !important;
666
+ }
667
+
668
+ .lyrics-line.active:not(.lyrics-gap)
669
+ .background-vocal-container
670
+ .lyrics-syllable.line-synced,
671
+ .lyrics-line.pre-active
672
+ .background-vocal-container
673
+ .lyrics-syllable.line-synced {
674
+ color: color-mix(
675
+ in srgb,
676
+ var(--lyplus-text-primary) 50%,
677
+ #888888
678
+ ) !important;
679
+ }
680
+
681
+ .lyrics-line.active:not(.lyrics-gap)
682
+ .background-vocal-container
683
+ .lyrics-syllable.line-synced.finished,
684
+ .lyrics-line.pre-active
685
+ .background-vocal-container
686
+ .lyrics-syllable.line-synced.finished {
687
+ color: color-mix(
688
+ in srgb,
689
+ var(--lyplus-text-primary) 50%,
690
+ #888888
691
+ ) !important;
509
692
  }
510
693
 
511
694
  .lyrics-syllable.pre-highlight {
@@ -549,8 +732,11 @@ export class AmLyrics extends LitElement {
549
732
  }
550
733
 
551
734
  .lyrics-syllable.finished span.char {
552
- transition: color 0.18s;
553
735
  background-color: var(--lyplus-text-primary);
736
+ transition:
737
+ color 0.7s,
738
+ background-color 0.7s,
739
+ transform 0.7s ease;
554
740
  }
555
741
 
556
742
  /* Active char spans: structural only, wipe animation sets gradient */
@@ -741,7 +927,7 @@ export class AmLyrics extends LitElement {
741
927
  position: relative;
742
928
  box-sizing: border-box;
743
929
  font-weight: normal;
744
- transform: translateY(var(--lyrics-scroll-offset, 0px)) translateZ(1px);
930
+ transform: translateY(var(--lyrics-scroll-offset, 0px));
745
931
  transition:
746
932
  opacity 0.3s ease,
747
933
  transform 0.6s cubic-bezier(0.23, 1, 0.32, 1)
@@ -752,7 +938,7 @@ export class AmLyrics extends LitElement {
752
938
  .lyrics-plus-empty {
753
939
  display: block;
754
940
  height: 100vh;
755
- transform: translateY(var(--lyrics-scroll-offset, 0px)) translateZ(1px);
941
+ transform: translateY(var(--lyrics-scroll-offset, 0px));
756
942
  }
757
943
 
758
944
  .lyrics-footer {
@@ -761,8 +947,8 @@ export class AmLyrics extends LitElement {
761
947
  align-items: center;
762
948
  flex-wrap: wrap;
763
949
  text-align: left;
764
- font-size: 1.2em;
765
- color: rgba(255, 255, 255, 0.6);
950
+ font-size: calc(var(--lyplus-font-size-base) * 0.5);
951
+ color: var(--lyplus-text-secondary);
766
952
  padding: 20px 0 50vh 0;
767
953
  margin-top: 10px;
768
954
  font-weight: 400;
@@ -775,9 +961,9 @@ export class AmLyrics extends LitElement {
775
961
  }
776
962
 
777
963
  .lyrics-footer.lyrics-line {
778
- font-size: 1.2em;
964
+ font-size: calc(var(--lyplus-font-size-base) * 0.5);
779
965
  padding: 20px var(--lyplus-padding-line) 50vh var(--lyplus-padding-line);
780
- cursor: default;
966
+ margin-top: 0;
781
967
  }
782
968
 
783
969
  .lyrics-footer.active {
@@ -1065,7 +1251,7 @@ export class AmLyrics extends LitElement {
1065
1251
  0% 100%;
1066
1252
  background-position:
1067
1253
  calc(100% + 0.375em) 0%,
1068
- calc(100% + 0.36em);
1254
+ calc(100% + 0.36em) 0%;
1069
1255
  }
1070
1256
  to {
1071
1257
  background-size:
@@ -1073,7 +1259,7 @@ export class AmLyrics extends LitElement {
1073
1259
  100% 100%;
1074
1260
  background-position:
1075
1261
  -0.75em 0%,
1076
- right;
1262
+ right 0%;
1077
1263
  }
1078
1264
  }
1079
1265
 
@@ -1084,7 +1270,7 @@ export class AmLyrics extends LitElement {
1084
1270
  0% 100%;
1085
1271
  background-position:
1086
1272
  calc(100% + 0.75em) 0%,
1087
- calc(100% + 0.5em);
1273
+ calc(100% + 0.5em) 0%;
1088
1274
  }
1089
1275
  100% {
1090
1276
  background-size:
@@ -1092,7 +1278,7 @@ export class AmLyrics extends LitElement {
1092
1278
  100% 100%;
1093
1279
  background-position:
1094
1280
  -0.75em 0%,
1095
- right;
1281
+ right 0%;
1096
1282
  }
1097
1283
  }
1098
1284
 
@@ -1122,7 +1308,7 @@ export class AmLyrics extends LitElement {
1122
1308
  0% 100%;
1123
1309
  background-position:
1124
1310
  calc(100% + 0.75em) 0%,
1125
- right;
1311
+ right 0%;
1126
1312
  }
1127
1313
  to {
1128
1314
  background-size:
@@ -1130,7 +1316,7 @@ export class AmLyrics extends LitElement {
1130
1316
  0% 100%;
1131
1317
  background-position:
1132
1318
  calc(100% + 0.375em) 0%,
1133
- right;
1319
+ right 0%;
1134
1320
  }
1135
1321
  }
1136
1322
 
@@ -1196,7 +1382,7 @@ export class AmLyrics extends LitElement {
1196
1382
  }
1197
1383
 
1198
1384
  /* Character grow animation — translate3d+scale3d for smooth transform,
1199
- drop-shadow for glow (text-shadow doesn't work with background-clip:text) */
1385
+ drop-shadow for glow */
1200
1386
  @keyframes grow-dynamic {
1201
1387
  0% {
1202
1388
  transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
@@ -1233,6 +1419,16 @@ export class AmLyrics extends LitElement {
1233
1419
  }
1234
1420
  }
1235
1421
 
1422
+ @keyframes rise-char {
1423
+ 0% {
1424
+ transform: translate3d(0, 0, 0);
1425
+ }
1426
+ 65%,
1427
+ 100% {
1428
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
1429
+ }
1430
+ }
1431
+
1236
1432
  @keyframes grow-static {
1237
1433
  0%,
1238
1434
  100% {
@@ -1479,10 +1675,10 @@ export class AmLyrics extends LitElement {
1479
1675
  // Stop all running animations and clear highlights immediately
1480
1676
  if (this.lyricsContainer) {
1481
1677
  const activeLines = this.lyricsContainer.querySelectorAll(
1482
- '.lyrics-line.active, .lyrics-line.pre-active',
1678
+ '.lyrics-line.active, .lyrics-line.pre-active, .lyrics-line.bg-expanded',
1483
1679
  );
1484
1680
  activeLines.forEach(line => {
1485
- line.classList.remove('active', 'pre-active');
1681
+ line.classList.remove('active', 'pre-active', 'bg-expanded');
1486
1682
  AmLyrics.resetSyllables(line as HTMLElement);
1487
1683
  });
1488
1684
 
@@ -1589,6 +1785,9 @@ export class AmLyrics extends LitElement {
1589
1785
  // Cached DOM elements for animation updates
1590
1786
  private cachedLyricsLines: HTMLElement[] = [];
1591
1787
 
1788
+ // Cached line elements array for scroll/position queries
1789
+ private cachedLineArray: HTMLElement[] = [];
1790
+
1592
1791
  // Cached line and gap element maps for fast lookup
1593
1792
  private lineElementCache = new Map<number, HTMLElement>();
1594
1793
 
@@ -1609,6 +1808,7 @@ export class AmLyrics extends LitElement {
1609
1808
  wordGroups: Syllable[][];
1610
1809
  groupGrowable: boolean[];
1611
1810
  groupGlowing: boolean[];
1811
+ groupCharRise: boolean[];
1612
1812
  vwFullText: string[];
1613
1813
  vwFullDuration: number[];
1614
1814
  vwCharOffset: number[];
@@ -1634,10 +1834,10 @@ export class AmLyrics extends LitElement {
1634
1834
 
1635
1835
  private animatingLines: HTMLElement[] = [];
1636
1836
 
1637
- private scrollUnlockTimeout?: ReturnType<typeof setTimeout>;
1638
-
1639
1837
  private scrollAnimationTimeout?: ReturnType<typeof setTimeout>;
1640
1838
 
1839
+ private scrollUnlockTimeout?: ReturnType<typeof setTimeout>;
1840
+
1641
1841
  // AbortController for cancelling in-flight lyrics fetches
1642
1842
  private fetchAbortController?: AbortController;
1643
1843
 
@@ -1646,6 +1846,12 @@ export class AmLyrics extends LitElement {
1646
1846
 
1647
1847
  private visibleLineIds: Set<string> = new Set();
1648
1848
 
1849
+ // IntersectionObserver for viewport virtualization
1850
+ private visibilityObserver?: IntersectionObserver;
1851
+
1852
+ // Cached scroll padding top value
1853
+ private cachedScrollPaddingTop: number | null = null;
1854
+
1649
1855
  // Cached element tracking to avoid repeated querySelectorAll calls
1650
1856
  private preActiveLineElements: HTMLElement[] = [];
1651
1857
 
@@ -1677,14 +1883,14 @@ export class AmLyrics extends LitElement {
1677
1883
  clearTimeout(this.clickSeekTimeout);
1678
1884
  this.clickSeekTimeout = undefined;
1679
1885
  }
1680
- if (this.scrollUnlockTimeout) {
1681
- clearTimeout(this.scrollUnlockTimeout);
1682
- this.scrollUnlockTimeout = undefined;
1683
- }
1684
1886
  if (this.scrollAnimationTimeout) {
1685
1887
  clearTimeout(this.scrollAnimationTimeout);
1686
1888
  this.scrollAnimationTimeout = undefined;
1687
1889
  }
1890
+ if (this.scrollUnlockTimeout) {
1891
+ clearTimeout(this.scrollUnlockTimeout);
1892
+ this.scrollUnlockTimeout = undefined;
1893
+ }
1688
1894
  // Cancel any in-flight fetch requests
1689
1895
  this.fetchAbortController?.abort();
1690
1896
  this.fetchAbortController = undefined;
@@ -1702,6 +1908,8 @@ export class AmLyrics extends LitElement {
1702
1908
  this.preActiveLineElements = [];
1703
1909
  this.positionedLineElements = [];
1704
1910
  this.activeGapLineElements = [];
1911
+ this.visibilityObserver?.disconnect();
1912
+ this.visibilityObserver = undefined;
1705
1913
  }
1706
1914
 
1707
1915
  private async fetchLyrics() {
@@ -1736,15 +1944,37 @@ export class AmLyrics extends LitElement {
1736
1944
  const title = resolvedMetadata.metadata.title?.trim() || '';
1737
1945
  const artist = resolvedMetadata.metadata.artist?.trim() || '';
1738
1946
 
1739
- const youLyResults = await AmLyrics.fetchLyricsFromYouLyPlus(
1947
+ const biniResult = await AmLyrics.fetchLyricsFromBiniLyrics(
1740
1948
  title,
1741
1949
  artist,
1742
1950
  resolvedMetadata.catalogIsrc,
1743
1951
  resolvedMetadata.metadata,
1744
1952
  );
1953
+ if (biniResult && biniResult.lines.length > 0) {
1954
+ collectedSources.push(biniResult);
1955
+ }
1956
+
1957
+ if (collectedSources.length === 0) {
1958
+ const unisonResult = await AmLyrics.fetchLyricsFromUnison(
1959
+ resolvedMetadata.metadata,
1960
+ );
1961
+ if (unisonResult && unisonResult.lines.length > 0) {
1962
+ collectedSources.push(unisonResult);
1963
+ }
1964
+ }
1965
+
1966
+ if (collectedSources.length === 0) {
1967
+ const youLyResults = await AmLyrics.fetchLyricsFromYouLyPlus(
1968
+ title,
1969
+ artist,
1970
+ resolvedMetadata.catalogIsrc,
1971
+ resolvedMetadata.metadata,
1972
+ true,
1973
+ );
1745
1974
 
1746
- if (youLyResults && youLyResults.length > 0) {
1747
- collectedSources.push(...youLyResults);
1975
+ if (youLyResults && youLyResults.length > 0) {
1976
+ collectedSources.push(...youLyResults);
1977
+ }
1748
1978
  }
1749
1979
  }
1750
1980
 
@@ -1853,24 +2083,30 @@ export class AmLyrics extends LitElement {
1853
2083
  const isQQ = lower.includes('qq') || lower.includes('lyricsplus');
1854
2084
 
1855
2085
  if (lower.includes('apple') && hasWordSync) return 1;
1856
- if (isQQ && hasWordSync) return 2;
1857
- if (lower.includes('musixmatch') && hasWordSync) return 3;
1858
- if (lower.includes('lrclib') && hasWordSync) return 4;
1859
- if (hasWordSync) return 5;
1860
-
1861
- if (lower.includes('apple') && !hasWordSync && !isUnsynced) return 6;
1862
- if (isQQ && !hasWordSync && !isUnsynced) return 7;
1863
- if (lower.includes('musixmatch') && !hasWordSync && !isUnsynced) return 8;
1864
- if (lower.includes('lrclib') && !hasWordSync && !isUnsynced) return 9;
1865
- if (!hasWordSync && !isUnsynced) return 10;
1866
-
1867
- if (lower.includes('apple') && isUnsynced) return 11;
1868
- if (isQQ && isUnsynced) return 12;
1869
- if (lower.includes('musixmatch') && isUnsynced) return 13;
1870
- if (lower.includes('lrclib') && isUnsynced) return 14;
1871
- if (lower.includes('genius')) return 15;
1872
-
1873
- return 20;
2086
+ if (lower.includes('bini') && hasWordSync) return 2;
2087
+ if (lower.includes('unison') && hasWordSync) return 3;
2088
+ if (isQQ && hasWordSync) return 4;
2089
+ if (lower.includes('musixmatch') && hasWordSync) return 5;
2090
+ if (lower.includes('lrclib') && hasWordSync) return 6;
2091
+ if (hasWordSync) return 7;
2092
+
2093
+ if (lower.includes('apple') && !hasWordSync && !isUnsynced) return 8;
2094
+ if (lower.includes('bini') && !hasWordSync && !isUnsynced) return 9;
2095
+ if (lower.includes('unison') && !hasWordSync && !isUnsynced) return 10;
2096
+ if (isQQ && !hasWordSync && !isUnsynced) return 11;
2097
+ if (lower.includes('musixmatch') && !hasWordSync && !isUnsynced) return 12;
2098
+ if (lower.includes('lrclib') && !hasWordSync && !isUnsynced) return 13;
2099
+ if (!hasWordSync && !isUnsynced) return 14;
2100
+
2101
+ if (lower.includes('apple') && isUnsynced) return 15;
2102
+ if (lower.includes('bini') && isUnsynced) return 16;
2103
+ if (lower.includes('unison') && isUnsynced) return 17;
2104
+ if (isQQ && isUnsynced) return 18;
2105
+ if (lower.includes('musixmatch') && isUnsynced) return 19;
2106
+ if (lower.includes('lrclib') && isUnsynced) return 20;
2107
+ if (lower.includes('genius')) return 21;
2108
+
2109
+ return 30;
1874
2110
  }
1875
2111
 
1876
2112
  private static mergeAndSortSources(
@@ -2153,73 +2389,17 @@ export class AmLyrics extends LitElement {
2153
2389
  return null;
2154
2390
  }
2155
2391
 
2156
- private static async fetchLyricsFromYouLyPlus(
2392
+ private static async fetchLyricsFromBiniLyrics(
2157
2393
  title: string,
2158
2394
  artist: string,
2159
2395
  isrc?: string,
2160
2396
  metadata: { durationMs?: number; album?: string } = {},
2161
- ): Promise<YouLyPlusLyricsResult[]> {
2162
- if ((!title || !artist) && !isrc) return [];
2163
-
2164
- const params = new URLSearchParams();
2165
- if (title) params.append('title', title);
2166
- if (artist) params.append('artist', artist);
2167
- if (isrc) params.append('isrc', isrc);
2168
-
2169
- if (metadata.album) {
2170
- params.append('album', metadata.album);
2171
- }
2172
-
2173
- if (metadata.durationMs && metadata.durationMs > 0) {
2174
- params.append(
2175
- 'duration',
2176
- Math.round(metadata.durationMs / 1000).toString(),
2177
- );
2178
- }
2179
-
2180
- if (!DEFAULT_KPOE_SOURCE_ORDER.includes('apple')) {
2181
- params.append('source', DEFAULT_KPOE_SOURCE_ORDER);
2182
- }
2183
-
2184
- const getRank = (sourceLabel: string, parsedLines: any[]): number => {
2185
- const lower = sourceLabel.toLowerCase();
2186
- const hasWordSync = parsedLines.some(
2187
- (line: any) =>
2188
- line.text && Array.isArray(line.text) && line.text.length > 1,
2189
- );
2190
-
2191
- const isUnsynced =
2192
- parsedLines.length > 0 &&
2193
- parsedLines.every(
2194
- (line: any) => line.timestamp === 0 && line.endtime === 0,
2195
- );
2196
-
2197
- const isQQ = lower.includes('qq') || lower.includes('lyricsplus');
2198
-
2199
- if (lower.includes('apple') && hasWordSync) return 1;
2200
- if (isQQ && hasWordSync) return 2;
2201
- if (lower.includes('musixmatch') && hasWordSync) return 3;
2202
- if (hasWordSync) return 4;
2203
-
2204
- if (lower.includes('apple') && !hasWordSync && !isUnsynced) return 5;
2205
- if (isQQ && !hasWordSync && !isUnsynced) return 6;
2206
- if (lower.includes('musixmatch') && !hasWordSync && !isUnsynced) return 7;
2207
- if (!hasWordSync && !isUnsynced) return 8;
2208
-
2209
- if (lower.includes('apple') && isUnsynced) return 9;
2210
- if (isQQ && isUnsynced) return 10;
2211
- if (lower.includes('musixmatch') && isUnsynced) return 11;
2212
-
2213
- return 20;
2214
- };
2215
-
2216
- const allResults: YouLyPlusLyricsResult[] = [];
2397
+ ): Promise<YouLyPlusLyricsResult | null> {
2398
+ if ((!title || !artist) && !isrc) return null;
2217
2399
 
2218
- // Try BiniLyrics cache API first
2219
2400
  try {
2220
2401
  let cacheData: any = null;
2221
2402
 
2222
- // First attempt: Prefer ISRC search if available
2223
2403
  if (isrc) {
2224
2404
  try {
2225
2405
  const isrcUrl = `https://lyrics-api.binimum.org/?isrc=${encodeURIComponent(isrc)}`;
@@ -2230,12 +2410,11 @@ export class AmLyrics extends LitElement {
2230
2410
  cacheData = data;
2231
2411
  }
2232
2412
  }
2233
- } catch (isrcErr) {
2413
+ } catch {
2234
2414
  // Fall through to title/artist search
2235
2415
  }
2236
2416
  }
2237
2417
 
2238
- // Second attempt: Fallback to title and artist search if ISRC search failed or was not available
2239
2418
  if (!cacheData && title && artist) {
2240
2419
  const cacheParams = new URLSearchParams({
2241
2420
  track: title,
@@ -2260,60 +2439,17 @@ export class AmLyrics extends LitElement {
2260
2439
 
2261
2440
  if (cacheData && cacheData.results && cacheData.results.length > 0) {
2262
2441
  const result = cacheData.results[0];
2263
- if (result.timing_type === 'word' && result.lyricsUrl) {
2442
+ if (result.lyricsUrl) {
2264
2443
  const ttmlRes = await fetchWithTimeout(result.lyricsUrl);
2265
2444
  if (ttmlRes.ok) {
2266
2445
  const ttmlText = await ttmlRes.text();
2267
2446
  const parseResult = AmLyrics.parseTTML(ttmlText);
2268
2447
  if (parseResult && parseResult.lines.length > 0) {
2269
- allResults.push({
2448
+ return {
2270
2449
  lines: parseResult.lines,
2271
2450
  source: 'BiniLyrics',
2272
2451
  songwriters: parseResult.songwriters,
2273
- });
2274
- return allResults;
2275
- }
2276
- }
2277
- } else {
2278
- // Not word type, try fetching any word synced lyrics from lyricsplus
2279
- const fallbackParams = new URLSearchParams(params);
2280
- const fallbackUrl = `https://lyricsplus.binimum.org/v2/lyrics/get?${fallbackParams.toString()}`;
2281
- try {
2282
- const fallbackRes = await fetchWithTimeout(fallbackUrl);
2283
- if (fallbackRes.ok) {
2284
- const payload = await fallbackRes.json();
2285
- const lines = AmLyrics.convertKPoeLyrics(payload);
2286
- const hasWordSync = lines?.some(
2287
- (line: any) =>
2288
- line.text && Array.isArray(line.text) && line.text.length > 1,
2289
- );
2290
- if (lines && lines.length > 0 && hasWordSync) {
2291
- const sourceLabel =
2292
- payload?.metadata?.source ||
2293
- payload?.metadata?.provider ||
2294
- 'LyricsPlus (KPoe)';
2295
- allResults.push({ lines, source: sourceLabel });
2296
- return allResults;
2297
- }
2298
- }
2299
- } catch (fallbackError) {
2300
- // Ignore fallback fetch error
2301
- }
2302
-
2303
- // If fallback fails or has no word sync, fall back to bini lyrics
2304
- if (result.lyricsUrl) {
2305
- const ttmlRes = await fetchWithTimeout(result.lyricsUrl);
2306
- if (ttmlRes.ok) {
2307
- const ttmlText = await ttmlRes.text();
2308
- const parseResult = AmLyrics.parseTTML(ttmlText);
2309
- if (parseResult && parseResult.lines.length > 0) {
2310
- allResults.push({
2311
- lines: parseResult.lines,
2312
- source: 'BiniLyrics',
2313
- songwriters: parseResult.songwriters,
2314
- });
2315
- return allResults;
2316
- }
2452
+ };
2317
2453
  }
2318
2454
  }
2319
2455
  }
@@ -2323,6 +2459,92 @@ export class AmLyrics extends LitElement {
2323
2459
  console.error('Cache API failed', e);
2324
2460
  }
2325
2461
 
2462
+ return null;
2463
+ }
2464
+
2465
+ private static async fetchLyricsFromYouLyPlus(
2466
+ title: string,
2467
+ artist: string,
2468
+ isrc?: string,
2469
+ metadata: { durationMs?: number; album?: string } = {},
2470
+ skipBiniCache = false,
2471
+ ): Promise<YouLyPlusLyricsResult[]> {
2472
+ if ((!title || !artist) && !isrc) return [];
2473
+
2474
+ const params = new URLSearchParams();
2475
+ if (title) params.append('title', title);
2476
+ if (artist) params.append('artist', artist);
2477
+ if (isrc) params.append('isrc', isrc);
2478
+
2479
+ if (metadata.album) {
2480
+ params.append('album', metadata.album);
2481
+ }
2482
+
2483
+ if (metadata.durationMs && metadata.durationMs > 0) {
2484
+ params.append(
2485
+ 'duration',
2486
+ Math.round(metadata.durationMs / 1000).toString(),
2487
+ );
2488
+ }
2489
+
2490
+ if (!DEFAULT_KPOE_SOURCE_ORDER.includes('apple')) {
2491
+ params.append('source', DEFAULT_KPOE_SOURCE_ORDER);
2492
+ }
2493
+
2494
+ const getRank = (sourceLabel: string, parsedLines: any[]): number => {
2495
+ const lower = sourceLabel.toLowerCase();
2496
+ const hasWordSync = parsedLines.some(
2497
+ (line: any) =>
2498
+ line.text && Array.isArray(line.text) && line.text.length > 1,
2499
+ );
2500
+
2501
+ const isUnsynced =
2502
+ parsedLines.length > 0 &&
2503
+ parsedLines.every(
2504
+ (line: any) => line.timestamp === 0 && line.endtime === 0,
2505
+ );
2506
+
2507
+ const isQQ = lower.includes('qq') || lower.includes('lyricsplus');
2508
+
2509
+ if (lower.includes('apple') && hasWordSync) return 1;
2510
+ if (lower.includes('bini') && hasWordSync) return 2;
2511
+ if (lower.includes('unison') && hasWordSync) return 3;
2512
+ if (isQQ && hasWordSync) return 4;
2513
+ if (lower.includes('musixmatch') && hasWordSync) return 5;
2514
+ if (hasWordSync) return 6;
2515
+
2516
+ if (lower.includes('apple') && !hasWordSync && !isUnsynced) return 7;
2517
+ if (lower.includes('bini') && !hasWordSync && !isUnsynced) return 8;
2518
+ if (lower.includes('unison') && !hasWordSync && !isUnsynced) return 9;
2519
+ if (isQQ && !hasWordSync && !isUnsynced) return 10;
2520
+ if (lower.includes('musixmatch') && !hasWordSync && !isUnsynced)
2521
+ return 11;
2522
+ if (!hasWordSync && !isUnsynced) return 12;
2523
+
2524
+ if (lower.includes('apple') && isUnsynced) return 13;
2525
+ if (lower.includes('bini') && isUnsynced) return 14;
2526
+ if (lower.includes('unison') && isUnsynced) return 15;
2527
+ if (isQQ && isUnsynced) return 16;
2528
+ if (lower.includes('musixmatch') && isUnsynced) return 17;
2529
+
2530
+ return 30;
2531
+ };
2532
+
2533
+ const allResults: YouLyPlusLyricsResult[] = [];
2534
+
2535
+ if (!skipBiniCache) {
2536
+ const biniResult = await AmLyrics.fetchLyricsFromBiniLyrics(
2537
+ title,
2538
+ artist,
2539
+ isrc,
2540
+ metadata,
2541
+ );
2542
+ if (biniResult) {
2543
+ allResults.push(biniResult);
2544
+ return allResults;
2545
+ }
2546
+ }
2547
+
2326
2548
  // Shuffle servers so we pick a random one first, with all others as fallback
2327
2549
  // Try up to 3 servers to improve reliability when some have CORS or connectivity issues
2328
2550
  const shuffledServers = [...KPOE_SERVERS]
@@ -2596,15 +2818,94 @@ export class AmLyrics extends LitElement {
2596
2818
  return null;
2597
2819
  }
2598
2820
 
2599
- private static calculateLineAlignments(
2600
- lineSingers: (string | undefined)[],
2601
- agentTypes: Record<string, string>,
2602
- ): ('start' | 'end' | undefined)[] {
2603
- const lineSideAssignments = new Array(lineSingers.length).fill(undefined);
2604
- let currentSideIsLeft = true;
2605
- let lastPersonSingerId: string | null = null;
2606
- let rightCount = 0;
2607
- let totalCount = 0;
2821
+ private static async fetchLyricsFromUnison(
2822
+ metadata: SongMetadata,
2823
+ ): Promise<YouLyPlusLyricsResult | null> {
2824
+ const title = metadata.title?.trim();
2825
+ const artist = metadata.artist?.trim();
2826
+ if (!title || !artist) return null;
2827
+
2828
+ const params = new URLSearchParams();
2829
+ params.append('song', title);
2830
+ params.append('artist', artist);
2831
+ if (metadata.album) {
2832
+ params.append('album', metadata.album);
2833
+ }
2834
+ if (metadata.durationMs && metadata.durationMs > 0) {
2835
+ params.append(
2836
+ 'duration',
2837
+ Math.round(metadata.durationMs / 1000).toString(),
2838
+ );
2839
+ }
2840
+
2841
+ try {
2842
+ const response = await fetchWithTimeout(
2843
+ `https://unison.boidu.dev/lyrics?${params.toString()}`,
2844
+ );
2845
+ if (!response.ok) return null;
2846
+
2847
+ const data = await response.json();
2848
+ if (!data.success || !data.data?.lyrics) return null;
2849
+
2850
+ const lyricsData = data.data;
2851
+ const format = lyricsData.format || 'lrc';
2852
+ const syncType = lyricsData.syncType || 'linesync';
2853
+ const lyricsText = lyricsData.lyrics;
2854
+
2855
+ if (format === 'ttml') {
2856
+ const parseResult = AmLyrics.parseTTML(lyricsText);
2857
+ if (parseResult && parseResult.lines.length > 0) {
2858
+ return {
2859
+ lines: parseResult.lines,
2860
+ source: 'Unison',
2861
+ songwriters: parseResult.songwriters,
2862
+ };
2863
+ }
2864
+ }
2865
+
2866
+ if (format === 'lrc') {
2867
+ if (syncType === 'plain') {
2868
+ const plainLines = lyricsText
2869
+ .split('\n')
2870
+ .map((l: string) => l.trim())
2871
+ .filter((l: string) => l);
2872
+ if (plainLines.length > 0) {
2873
+ const lines: LyricsLine[] = plainLines.map(
2874
+ (text: string): LyricsLine => ({
2875
+ text: [{ text, part: false, timestamp: 0, endtime: 0 }],
2876
+ background: false,
2877
+ backgroundText: [],
2878
+ oppositeTurn: false,
2879
+ timestamp: 0,
2880
+ endtime: 0,
2881
+ isWordSynced: false,
2882
+ }),
2883
+ );
2884
+ return { lines, source: 'Unison (unsynced)' };
2885
+ }
2886
+ } else {
2887
+ const lines = AmLyrics.parseLrcSubtitles(lyricsText);
2888
+ if (lines.length > 0) {
2889
+ return { lines, source: 'Unison' };
2890
+ }
2891
+ }
2892
+ }
2893
+ } catch {
2894
+ // Unison fetch failed
2895
+ }
2896
+
2897
+ return null;
2898
+ }
2899
+
2900
+ private static calculateLineAlignments(
2901
+ lineSingers: (string | undefined)[],
2902
+ agentTypes: Record<string, string>,
2903
+ ): ('start' | 'end' | undefined)[] {
2904
+ const lineSideAssignments = new Array(lineSingers.length).fill(undefined);
2905
+ let currentSideIsLeft = true;
2906
+ let lastPersonSingerId: string | null = null;
2907
+ let rightCount = 0;
2908
+ let totalCount = 0;
2608
2909
 
2609
2910
  lineSingers.forEach((singerId, index) => {
2610
2911
  let sideClass: 'start' | 'end' | undefined;
@@ -3163,24 +3464,36 @@ export class AmLyrics extends LitElement {
3163
3464
 
3164
3465
  if (linesChanged || isSeek) {
3165
3466
  if (this.lyricsContainer) {
3166
- // Remove 'active' from lines that are no longer active
3467
+ // Remove .active and .bg-expanded immediately when a line drops.
3468
+ // All visual fading is handled by CSS transitions — no JS delays,
3469
+ // so overlapping lyrics never get stuck with multiple .active lines.
3167
3470
  for (const lineIndex of oldActiveLines) {
3168
3471
  if (!newActiveLines.includes(lineIndex)) {
3169
3472
  const lineElement = this._getLineElement(lineIndex);
3170
3473
  if (lineElement) {
3171
- lineElement.classList.remove('active', 'pre-active');
3474
+ if (isSeek || this.isUserScrolling) {
3475
+ AmLyrics.unfinishSyllables(lineElement);
3476
+ } else {
3477
+ AmLyrics.finishSyllablesUpToTime(lineElement, newTime);
3478
+ }
3479
+
3480
+ lineElement.classList.remove('active', 'bg-expanded');
3481
+
3482
+ if (lineElement.classList.contains('pre-active')) {
3483
+ lineElement.classList.remove('pre-active');
3484
+ }
3172
3485
  const preIdx = this.preActiveLineElements.indexOf(lineElement);
3173
3486
  if (preIdx !== -1) this.preActiveLineElements.splice(preIdx, 1);
3174
- AmLyrics.resetSyllables(lineElement);
3175
3487
  }
3176
3488
  }
3177
3489
  }
3178
- // Add 'active' to newly active lines
3490
+
3491
+ // Add 'active' and 'bg-expanded' to newly active lines
3179
3492
  for (const lineIndex of newActiveLines) {
3180
3493
  if (!oldActiveLines.includes(lineIndex)) {
3181
3494
  const lineElement = this._getLineElement(lineIndex);
3182
3495
  if (lineElement) {
3183
- lineElement.classList.add('active');
3496
+ lineElement.classList.add('active', 'bg-expanded');
3184
3497
  lineElement.classList.remove('pre-active');
3185
3498
  const preIdx = this.preActiveLineElements.indexOf(lineElement);
3186
3499
  if (preIdx !== -1) this.preActiveLineElements.splice(preIdx, 1);
@@ -3188,16 +3501,31 @@ export class AmLyrics extends LitElement {
3188
3501
  }
3189
3502
  }
3190
3503
 
3191
- if (newActiveLines.length > 0) {
3192
- this.clearPreActiveClasses();
3504
+ // Remove pre-active from lines that are now active (they no longer
3505
+ // need the unblur preview class) and from lines that dropped.
3506
+ for (const lineElement of this.preActiveLineElements) {
3507
+ const idx = AmLyrics.getLineIndexFromElement(lineElement);
3508
+ if (
3509
+ idx === null ||
3510
+ (!newActiveLines.includes(idx) &&
3511
+ lineElement !== this.currentPrimaryActiveLine)
3512
+ ) {
3513
+ lineElement.classList.remove('pre-active');
3514
+ }
3193
3515
  }
3516
+ this.preActiveLineElements = this.preActiveLineElements.filter(el =>
3517
+ el.classList.contains('pre-active'),
3518
+ );
3194
3519
  }
3195
3520
 
3196
3521
  this.startAnimationFromTime(newTime);
3197
-
3198
- this._handleActiveLineScroll(oldActiveLines, isSeek);
3199
3522
  }
3200
3523
 
3524
+ // Predictive scroll: run on every tick so we scroll *before* the next
3525
+ // line starts, matching YouLyPlus behaviour.
3526
+ this._handleActiveLineScroll(oldActiveLines, isSeek);
3527
+ this.clearPastLineHighlights();
3528
+
3201
3529
  if (this.lyricsContainer) {
3202
3530
  // Update syllables in active lines using cached elements
3203
3531
  for (const lineIndex of this.activeLineIndices) {
@@ -3215,12 +3543,12 @@ export class AmLyrics extends LitElement {
3215
3543
  // Imperatively manage gap active state
3216
3544
  if (this.gapElementCache.size > 0) {
3217
3545
  for (const [, gap] of this.gapElementCache) {
3218
- const gapStartTime = parseFloat(
3219
- gap.getAttribute('data-start-time') || '0',
3220
- );
3221
- const gapEndTime = parseFloat(
3222
- gap.getAttribute('data-end-time') || '0',
3223
- );
3546
+ const gapStartTime =
3547
+ (gap as any)._cachedStartTime ??
3548
+ parseFloat(gap.getAttribute('data-start-time') || '0');
3549
+ const gapEndTime =
3550
+ (gap as any)._cachedEndTime ??
3551
+ parseFloat(gap.getAttribute('data-end-time') || '0');
3224
3552
  const shouldBeActive =
3225
3553
  newTime >= gapStartTime && newTime < gapEndTime;
3226
3554
  const isActive = gap.classList.contains('active');
@@ -3377,6 +3705,16 @@ export class AmLyrics extends LitElement {
3377
3705
  const isFooterActive = newTime > lastLyric.endtime + 200; // Snappier 200ms buffer
3378
3706
  if (isFooterActive && !footer.classList.contains('active')) {
3379
3707
  footer.classList.add('active');
3708
+ // Clear pre-active from the last lyric so it doesn't stay
3709
+ // unblurred when the footer takes over.
3710
+ const lastLine = this.lyricsContainer.querySelector(
3711
+ '.lyrics-line:last-of-type',
3712
+ ) as HTMLElement;
3713
+ if (lastLine) {
3714
+ lastLine.classList.remove('pre-active');
3715
+ const preIdx = this.preActiveLineElements.indexOf(lastLine);
3716
+ if (preIdx !== -1) this.preActiveLineElements.splice(preIdx, 1);
3717
+ }
3380
3718
  if (
3381
3719
  this.autoScroll &&
3382
3720
  !this.isUserScrolling &&
@@ -3388,59 +3726,6 @@ export class AmLyrics extends LitElement {
3388
3726
  footer.classList.remove('active');
3389
3727
  }
3390
3728
  }
3391
-
3392
- // Pre-scroll: scroll to upcoming line ~0.5s before it starts
3393
- if (
3394
- this.autoScroll &&
3395
- !this.isUserScrolling &&
3396
- !this.isClickSeeking &&
3397
- this.lyrics
3398
- ) {
3399
- let preActiveLineIndex: number | null = null;
3400
-
3401
- for (let i = 0; i < this.lyrics.length; i += 1) {
3402
- const line = this.lyrics[i];
3403
- const timeUntilStart = line.timestamp - newTime;
3404
-
3405
- const nextLineEl = this._getLineElement(i);
3406
-
3407
- const isBackToBack = this.activeLineIndices.length > 0;
3408
- const leadTime = isBackToBack
3409
- ? PRE_SCROLL_LEAD_SHORT_MS
3410
- : PRE_SCROLL_LEAD_MS;
3411
-
3412
- if (timeUntilStart > leadTime) {
3413
- break;
3414
- }
3415
-
3416
- if (timeUntilStart > 0 && timeUntilStart <= leadTime) {
3417
- if (nextLineEl) {
3418
- preActiveLineIndex = i;
3419
-
3420
- if (!isBackToBack) {
3421
- nextLineEl.classList.add('pre-active');
3422
- if (!this.preActiveLineElements.includes(nextLineEl)) {
3423
- this.preActiveLineElements.push(nextLineEl);
3424
- }
3425
- }
3426
- this.clearPreActiveClasses(i);
3427
-
3428
- const slowScrollDuration = Math.max(
3429
- SCROLL_ANIMATION_DURATION_MS,
3430
- timeUntilStart,
3431
- );
3432
- this.focusLine(
3433
- nextLineEl,
3434
- false,
3435
- isBackToBack ? 500 : slowScrollDuration,
3436
- );
3437
- }
3438
- break;
3439
- }
3440
- }
3441
-
3442
- this.clearPreActiveClasses(preActiveLineIndex);
3443
- }
3444
3729
  }
3445
3730
  }
3446
3731
 
@@ -3459,12 +3744,38 @@ export class AmLyrics extends LitElement {
3459
3744
  const activeLines = this.findActiveLineIndices(this.currentTime);
3460
3745
  for (const lineIndex of activeLines) {
3461
3746
  const lineEl = this._getLineElement(lineIndex);
3462
- if (lineEl) lineEl.classList.add('active');
3747
+ if (lineEl) lineEl.classList.add('active', 'bg-expanded');
3463
3748
  }
3464
3749
 
3465
3750
  // Trigger a faux time-change so that updateSyllablesForLine fires
3466
3751
  // to setup inline syllable CSS wipe animations for whatever the current time is
3467
3752
  this._onTimeChanged(0, this.currentTime);
3753
+
3754
+ // Ensure position classes are applied on initial render if not playing yet
3755
+ if (this.positionedLineElements.length === 0) {
3756
+ const firstLine = this.lyricsContainer.querySelector(
3757
+ '.lyrics-line',
3758
+ ) as HTMLElement;
3759
+ if (firstLine) this.updatePositionClasses(firstLine);
3760
+ }
3761
+
3762
+ // Set up IntersectionObserver for viewport virtualization
3763
+ this.visibilityObserver?.disconnect();
3764
+ this.visibilityObserver = new IntersectionObserver(
3765
+ entries => {
3766
+ entries.forEach(entry => {
3767
+ const el = entry.target as HTMLElement;
3768
+ el.classList.toggle('far-line', !entry.isIntersecting);
3769
+ });
3770
+ },
3771
+ {
3772
+ root: this.lyricsContainer,
3773
+ rootMargin: '200px',
3774
+ threshold: 0,
3775
+ },
3776
+ );
3777
+ const lines = this.lyricsContainer.querySelectorAll('.lyrics-line');
3778
+ lines.forEach(line => this.visibilityObserver!.observe(line));
3468
3779
  }
3469
3780
  }
3470
3781
 
@@ -3494,6 +3805,14 @@ export class AmLyrics extends LitElement {
3494
3805
  clearTimeout(this.userScrollTimeoutId);
3495
3806
  this.userScrollTimeoutId = undefined;
3496
3807
  }
3808
+ if (this.scrollUnlockTimeout) {
3809
+ clearTimeout(this.scrollUnlockTimeout);
3810
+ this.scrollUnlockTimeout = undefined;
3811
+ }
3812
+ if (this.scrollAnimationTimeout) {
3813
+ clearTimeout(this.scrollAnimationTimeout);
3814
+ this.scrollAnimationTimeout = undefined;
3815
+ }
3497
3816
 
3498
3817
  // Scroll to top
3499
3818
  if (this.lyricsContainer) {
@@ -3526,45 +3845,73 @@ export class AmLyrics extends LitElement {
3526
3845
  /**
3527
3846
  * Handle scrolling when active line indices change.
3528
3847
  * Called imperatively from _onTimeChanged instead of from updated().
3848
+ *
3849
+ * Uses predictive scroll like YouLyPlus: computes a scrollLookAheadMs based
3850
+ * on the gap to the next line, finds the primary line at predictiveTime,
3851
+ * and scrolls with a duration matching the lookahead.
3529
3852
  */
3530
3853
  private _handleActiveLineScroll(
3531
3854
  _oldActiveIndices: number[],
3532
3855
  forceScroll = false,
3533
3856
  ): void {
3534
- if (this.activeLineIndices.length === 0 || !this.lyricsContainer) {
3857
+ if (!this.lyricsContainer || !this.lyrics || this.lyrics.length === 0) {
3535
3858
  return;
3536
3859
  }
3537
3860
 
3538
- const targetLineIndex = this.getPrimaryActiveLineIndex(
3539
- this.activeLineIndices,
3861
+ // If the footer is already active, it set up its own scroll.
3862
+ // Don't override it with a scroll back to the last lyric.
3863
+ const footer = this.lyricsContainer.querySelector('.lyrics-footer');
3864
+ if (footer?.classList.contains('active')) {
3865
+ return;
3866
+ }
3867
+
3868
+ // 1. Compute scroll lookahead based on gap to next line (YouLyPlus style)
3869
+ let scrollLookAheadMs = 350;
3870
+ const currentAudioIndex = this.getLineIndexAtTime(
3871
+ this.currentTime,
3872
+ this.lastActiveIndex,
3540
3873
  );
3541
- if (targetLineIndex === null) return;
3874
+ if (
3875
+ currentAudioIndex !== -1 &&
3876
+ currentAudioIndex + 1 < this.lyrics.length
3877
+ ) {
3878
+ const currentLine = this.lyrics[currentAudioIndex];
3879
+ const nextLine = this.lyrics[currentAudioIndex + 1];
3880
+ const gap = nextLine.timestamp - currentLine.endtime;
3881
+ scrollLookAheadMs = Math.min(500, Math.max(350, gap));
3882
+ }
3883
+
3884
+ // 2. Find scroll target at predictive time
3885
+ const predictiveTime = this.currentTime + scrollLookAheadMs;
3886
+ const predictiveActiveIndices = this.findActiveLineIndices(predictiveTime);
3887
+
3888
+ let targetLineIndex: number | null;
3889
+ if (predictiveActiveIndices.length > 0) {
3890
+ targetLineIndex = this.getPrimaryScrollLineIndex(
3891
+ predictiveActiveIndices,
3892
+ predictiveTime,
3893
+ );
3894
+ } else {
3895
+ // Fallback: closest line before predictiveTime
3896
+ targetLineIndex = this.getLineIndexAtTime(predictiveTime, 0);
3897
+ }
3898
+
3899
+ if (targetLineIndex === null || targetLineIndex === -1) return;
3542
3900
 
3543
3901
  const targetLine = this._getLineElement(targetLineIndex);
3544
3902
  if (!targetLine) return;
3545
3903
 
3546
- // Only scroll snappily when lines are essentially back-to-back.
3547
- // If there is any noticeable gap between them, scroll slower.
3548
- let scrollDuration: number | undefined;
3549
- const prevPrimaryIndex = AmLyrics.getLineIndexFromElement(
3550
- this.currentPrimaryActiveLine,
3551
- );
3552
- if (
3553
- prevPrimaryIndex !== null &&
3554
- targetLineIndex > prevPrimaryIndex &&
3555
- this.lyrics
3556
- ) {
3557
- const gap =
3558
- this.lyrics[targetLineIndex].timestamp -
3559
- this.lyrics[prevPrimaryIndex].endtime;
3560
- if (gap > 200) {
3561
- scrollDuration = Math.min(
3562
- Math.max(gap * 0.85, SCROLL_ANIMATION_DURATION_MS),
3563
- 4000,
3564
- );
3904
+ // Unblur the upcoming target line early (pre-active) so background
3905
+ // vocals start their max-height/opacity transition in sync with scroll.
3906
+ if (!targetLine.classList.contains('active')) {
3907
+ targetLine.classList.add('pre-active');
3908
+ if (!this.preActiveLineElements.includes(targetLine)) {
3909
+ this.preActiveLineElements.push(targetLine);
3565
3910
  }
3566
3911
  }
3567
3912
 
3913
+ const scrollDuration = scrollLookAheadMs;
3914
+
3568
3915
  this.focusLine(targetLine, forceScroll, scrollDuration);
3569
3916
  }
3570
3917
 
@@ -3591,6 +3938,7 @@ export class AmLyrics extends LitElement {
3591
3938
 
3592
3939
  this.lineElementCache.clear();
3593
3940
  this.gapElementCache.clear();
3941
+ this.cachedLineArray = [];
3594
3942
 
3595
3943
  if (!this.lyrics) return;
3596
3944
 
@@ -3603,8 +3951,21 @@ export class AmLyrics extends LitElement {
3603
3951
  const gapEl = this.lyricsContainer.querySelector(
3604
3952
  `#gap-${i}`,
3605
3953
  ) as HTMLElement | null;
3606
- if (gapEl) this.gapElementCache.set(i, gapEl);
3954
+ if (gapEl) {
3955
+ // Cache numeric timing values to avoid parseFloat on every frame
3956
+ (gapEl as any)._cachedStartTime = parseFloat(
3957
+ gapEl.getAttribute('data-start-time') || '0',
3958
+ );
3959
+ (gapEl as any)._cachedEndTime = parseFloat(
3960
+ gapEl.getAttribute('data-end-time') || '0',
3961
+ );
3962
+ this.gapElementCache.set(i, gapEl);
3963
+ }
3607
3964
  }
3965
+
3966
+ // Rebuild cached line array for scroll/position queries
3967
+ const lineElements = this.lyricsContainer.querySelectorAll('.lyrics-line');
3968
+ this.cachedLineArray = Array.from(lineElements) as HTMLElement[];
3608
3969
  }
3609
3970
 
3610
3971
  private _getLineElement(index: number): HTMLElement | null {
@@ -3635,9 +3996,13 @@ export class AmLyrics extends LitElement {
3635
3996
  this.cachedLineData = null;
3636
3997
  this.lineElementCache.clear();
3637
3998
  this.gapElementCache.clear();
3999
+ this.cachedLineArray = [];
4000
+ this.cachedScrollPaddingTop = null;
3638
4001
  this.preActiveLineElements = [];
3639
4002
  this.positionedLineElements = [];
3640
4003
  this.activeGapLineElements = [];
4004
+ this.visibilityObserver?.disconnect();
4005
+ this.visibilityObserver = undefined;
3641
4006
  }
3642
4007
 
3643
4008
  private _updateCachedIsUnsynced() {
@@ -3678,6 +4043,7 @@ export class AmLyrics extends LitElement {
3678
4043
 
3679
4044
  const groupGrowable: boolean[] = new Array(wordGroups.length).fill(false);
3680
4045
  const groupGlowing: boolean[] = new Array(wordGroups.length).fill(false);
4046
+ const groupCharRise: boolean[] = new Array(wordGroups.length).fill(false);
3681
4047
  const vwFullText: string[] = new Array(wordGroups.length).fill('');
3682
4048
  const vwFullDuration: number[] = new Array(wordGroups.length).fill(0);
3683
4049
  const vwCharOffset: number[] = new Array(wordGroups.length).fill(0);
@@ -3732,11 +4098,14 @@ export class AmLyrics extends LitElement {
3732
4098
  const isLineSynced =
3733
4099
  line.isWordSynced === false || line.text.some(s => s.lineSynced);
3734
4100
  const isGlowingVW = isGrowableVW && !isLineSynced;
4101
+ const isCharRiseVW =
4102
+ !isGrowableVW && !isLineSynced && !isCJK && !isRTL && wordLen >= 8;
3735
4103
 
3736
4104
  let charOff = 0;
3737
4105
  for (let gi = vwStart; gi <= vwEnd; gi += 1) {
3738
4106
  groupGrowable[gi] = isGrowableVW;
3739
4107
  groupGlowing[gi] = isGlowingVW;
4108
+ groupCharRise[gi] = isCharRiseVW;
3740
4109
  vwFullText[gi] = combinedText;
3741
4110
  vwFullDuration[gi] = combinedDuration;
3742
4111
  vwCharOffset[gi] = charOff;
@@ -3753,6 +4122,7 @@ export class AmLyrics extends LitElement {
3753
4122
  wordGroups,
3754
4123
  groupGrowable,
3755
4124
  groupGlowing,
4125
+ groupCharRise,
3756
4126
  vwFullText,
3757
4127
  vwFullDuration,
3758
4128
  vwCharOffset,
@@ -3776,12 +4146,12 @@ export class AmLyrics extends LitElement {
3776
4146
  const { font } = computedStyle; // Full font string
3777
4147
  const fontSize = parseFloat(computedStyle.fontSize);
3778
4148
 
3779
- const growableWords = this.shadowRoot.querySelectorAll(
3780
- '.lyrics-word.growable',
4149
+ const charTimedWords = this.shadowRoot.querySelectorAll(
4150
+ '.lyrics-word.growable, .lyrics-word.char-rise',
3781
4151
  );
3782
- if (!growableWords) return;
4152
+ if (!charTimedWords) return;
3783
4153
 
3784
- growableWords.forEach((wordSpan: any) => {
4154
+ charTimedWords.forEach((wordSpan: any) => {
3785
4155
  const syllableWraps = wordSpan.querySelectorAll('.lyrics-syllable-wrap');
3786
4156
 
3787
4157
  // Flatten syllables
@@ -3888,33 +4258,124 @@ export class AmLyrics extends LitElement {
3888
4258
  );
3889
4259
  if (
3890
4260
  currentPrimaryIndex !== null &&
3891
- activeIndices.includes(currentPrimaryIndex) &&
3892
- candidateIndex < currentPrimaryIndex
4261
+ activeIndices.includes(currentPrimaryIndex)
3893
4262
  ) {
3894
- candidateIndex = currentPrimaryIndex;
4263
+ if (activeIndices.length <= 3) {
4264
+ candidateIndex = currentPrimaryIndex;
4265
+ } else if (candidateIndex < currentPrimaryIndex) {
4266
+ candidateIndex = currentPrimaryIndex;
4267
+ }
3895
4268
  }
3896
4269
 
3897
4270
  return candidateIndex;
3898
4271
  }
3899
4272
 
4273
+ private getPrimaryScrollLineIndex(
4274
+ _activeIndices: number[],
4275
+ time: number,
4276
+ ): number | null {
4277
+ if (!this.lyrics || this.lyrics.length === 0) return null;
4278
+
4279
+ // YouLyPlus-style: primary is simply the line at predictive time.
4280
+ const primaryIndex = this.getLineIndexAtTime(time, this.lastActiveIndex);
4281
+ if (primaryIndex === -1) return null;
4282
+
4283
+ // Guard: if new primary is ahead of current but they share the same
4284
+ // end time, keep current to prevent bounce during overlaps.
4285
+ const currentPrimaryIndex = AmLyrics.getLineIndexFromElement(
4286
+ this.currentPrimaryActiveLine,
4287
+ );
4288
+ if (
4289
+ currentPrimaryIndex !== null &&
4290
+ primaryIndex > currentPrimaryIndex &&
4291
+ this.lyrics[currentPrimaryIndex] &&
4292
+ this.lyrics[primaryIndex] &&
4293
+ this.lyrics[currentPrimaryIndex].endtime ===
4294
+ this.lyrics[primaryIndex].endtime
4295
+ ) {
4296
+ const activeCount = this.findActiveLineIndices(time).length;
4297
+ if (activeCount <= 3) {
4298
+ return currentPrimaryIndex;
4299
+ }
4300
+ }
4301
+
4302
+ return primaryIndex;
4303
+ }
4304
+
4305
+ private getOverlapClusterForActiveIndices(
4306
+ activeIndices: number[],
4307
+ time: number,
4308
+ ): {
4309
+ start: number;
4310
+ end: number;
4311
+ startedEnd: number;
4312
+ startedEndTime: number;
4313
+ } | null {
4314
+ if (!this.lyrics || activeIndices.length === 0) return null;
4315
+
4316
+ let start = activeIndices[0];
4317
+ while (
4318
+ start > 0 &&
4319
+ this.lyrics[start - 1].endtime >= this.lyrics[start].timestamp
4320
+ ) {
4321
+ start -= 1;
4322
+ }
4323
+
4324
+ let end = start;
4325
+ let clusterEndTime = this.lyrics[start].endtime;
4326
+ while (
4327
+ end + 1 < this.lyrics.length &&
4328
+ this.lyrics[end + 1].timestamp <= clusterEndTime
4329
+ ) {
4330
+ end += 1;
4331
+ clusterEndTime = Math.max(clusterEndTime, this.lyrics[end].endtime);
4332
+ }
4333
+
4334
+ let startedEnd = start;
4335
+ let startedEndTime = this.lyrics[start].endtime;
4336
+ for (let i = start; i <= end; i += 1) {
4337
+ if (this.lyrics[i].timestamp <= time) {
4338
+ startedEnd = i;
4339
+ startedEndTime = Math.max(startedEndTime, this.lyrics[i].endtime);
4340
+ } else {
4341
+ break;
4342
+ }
4343
+ }
4344
+
4345
+ return { start, end, startedEnd, startedEndTime };
4346
+ }
4347
+
3900
4348
  private focusLine(
3901
4349
  lineElement: HTMLElement,
3902
4350
  forceScroll = false,
3903
4351
  scrollDuration: number | undefined = undefined,
3904
4352
  skipScroll = false,
4353
+ preservePrimary = false,
3905
4354
  ): void {
3906
4355
  const primaryChanged = lineElement !== this.currentPrimaryActiveLine;
3907
4356
 
3908
- if (primaryChanged) {
4357
+ if (primaryChanged && !preservePrimary) {
4358
+ // .active is now managed solely by findActiveLineIndices (which uses
4359
+ // effectiveEndTimes). Lines stay active until their extended end,
4360
+ // so we no longer need to remove .active here.
3909
4361
  this.lastPrimaryActiveLine = this.currentPrimaryActiveLine;
3910
4362
  this.currentPrimaryActiveLine = lineElement;
4363
+ const lineIndex = AmLyrics.getLineIndexFromElement(lineElement);
4364
+ if (lineIndex !== null) {
4365
+ this.lastActiveIndex = lineIndex;
4366
+ }
3911
4367
  }
3912
4368
 
3913
- this.updatePositionClasses(lineElement);
4369
+ // Only update blur/opacity position classes when the primary line
4370
+ // actually changes (or on force scroll). Running this every tick
4371
+ // causes visual churn and upward glitches.
4372
+ if (primaryChanged || forceScroll) {
4373
+ this.updatePositionClasses(lineElement);
4374
+ }
3914
4375
 
3915
4376
  if (
3916
4377
  !skipScroll &&
3917
- (forceScroll || primaryChanged) &&
4378
+ (forceScroll || primaryChanged || preservePrimary) &&
3918
4379
  this.autoScroll &&
3919
4380
  !this.isUserScrolling &&
3920
4381
  !this.isClickSeeking
@@ -3941,6 +4402,8 @@ export class AmLyrics extends LitElement {
3941
4402
  // Mark that user is currently scrolling
3942
4403
  this.setUserScrolling(true);
3943
4404
 
4405
+ this.clearPastLineHighlights();
4406
+
3944
4407
  // Clear any existing timeout
3945
4408
  if (this.userScrollTimeoutId) {
3946
4409
  clearTimeout(this.userScrollTimeoutId);
@@ -3953,31 +4416,82 @@ export class AmLyrics extends LitElement {
3953
4416
 
3954
4417
  // Optionally scroll back to current active line when re-enabling auto-scroll
3955
4418
  if (this.activeLineIndices.length > 0) {
3956
- this.scrollToActiveLine();
4419
+ this._handleActiveLineScroll([], false);
3957
4420
  }
3958
4421
  }, 2000);
3959
4422
  }
3960
4423
 
4424
+ private clearPastLineHighlights() {
4425
+ if (!this.lyricsContainer) return;
4426
+
4427
+ const lineElements = this.cachedLineArray.length
4428
+ ? this.cachedLineArray
4429
+ : (Array.from(
4430
+ this.lyricsContainer.querySelectorAll(
4431
+ '.lyrics-line:not(.lyrics-gap)',
4432
+ ),
4433
+ ) as HTMLElement[]);
4434
+ const containerRect = this.lyricsContainer.getBoundingClientRect();
4435
+ const anchorY = containerRect.top + this.getScrollPaddingTop();
4436
+
4437
+ for (let i = 0; i < lineElements.length; i += 1) {
4438
+ const lineElement = lineElements[i];
4439
+ const isActive = lineElement.classList.contains('active');
4440
+ const lineRect = lineElement.getBoundingClientRect();
4441
+ const hasScrolledPast = lineRect.bottom < anchorY - 2;
4442
+ if (!isActive && hasScrolledPast) {
4443
+ AmLyrics.unfinishSyllables(lineElement);
4444
+ }
4445
+ }
4446
+ }
4447
+
4448
+ /**
4449
+ * Find the first (lowest-index) line whose raw time range contains `timeMs`.
4450
+ * Uses a stable forward scan so overlapping ranges always return the same
4451
+ * line, preventing primary-target jitter that causes scroll glitches.
4452
+ */
4453
+ private getLineIndexAtTime(timeMs: number, startHintIndex = 0): number {
4454
+ if (!this.lyrics || this.lyrics.length === 0) return -1;
4455
+ const len = this.lyrics.length;
4456
+
4457
+ // 1. Check hint and immediate neighbours first (fast path)
4458
+ const hint = Math.max(0, Math.min(startHintIndex, len - 1));
4459
+ for (let i = hint; i < len; i += 1) {
4460
+ const line = this.lyrics[i];
4461
+ if (line.timestamp > timeMs) break;
4462
+ if (timeMs >= line.timestamp && timeMs < line.endtime) {
4463
+ return i;
4464
+ }
4465
+ }
4466
+ for (let i = hint - 1; i >= 0; i -= 1) {
4467
+ const line = this.lyrics[i];
4468
+ if (timeMs >= line.timestamp && timeMs < line.endtime) {
4469
+ return i;
4470
+ }
4471
+ if (line.endtime < timeMs) break;
4472
+ }
4473
+
4474
+ // 2. Full forward scan — guaranteed deterministic for overlaps
4475
+ for (let i = 0; i < len; i += 1) {
4476
+ const line = this.lyrics[i];
4477
+ if (line.timestamp > timeMs) break;
4478
+ if (timeMs >= line.timestamp && timeMs < line.endtime) {
4479
+ return i;
4480
+ }
4481
+ }
4482
+
4483
+ return -1;
4484
+ }
4485
+
3961
4486
  private findActiveLineIndices(time: number): number[] {
3962
4487
  if (!this.lyrics || this.lyrics.length === 0) return [];
3963
4488
  const activeLines: number[] = [];
3964
4489
 
3965
4490
  for (let i = 0; i < this.lyrics.length; i += 1) {
3966
4491
  const line = this.lyrics[i];
3967
- let effectiveEndTime = line.endtime;
3968
-
3969
- if (i < this.lyrics.length - 1) {
3970
- const nextLineStart = this.lyrics[i + 1].timestamp;
3971
- const gapDuration = nextLineStart - line.endtime;
3972
- if (gapDuration < INSTRUMENTAL_THRESHOLD_MS) {
3973
- if (effectiveEndTime < nextLineStart) {
3974
- effectiveEndTime = Math.max(effectiveEndTime, nextLineStart - 500);
3975
- }
3976
- }
3977
- }
3978
4492
 
3979
4493
  if (line.timestamp > time) break;
3980
- if (time >= line.timestamp && time <= effectiveEndTime) {
4494
+ if (time >= line.timestamp && time < line.endtime) {
3981
4495
  activeLines.push(i);
3982
4496
  }
3983
4497
  }
@@ -4227,10 +4741,6 @@ export class AmLyrics extends LitElement {
4227
4741
  }
4228
4742
 
4229
4743
  // Clear scroll animation timeouts
4230
- if (this.scrollUnlockTimeout) {
4231
- clearTimeout(this.scrollUnlockTimeout);
4232
- this.scrollUnlockTimeout = undefined;
4233
- }
4234
4744
  if (this.scrollAnimationTimeout) {
4235
4745
  clearTimeout(this.scrollAnimationTimeout);
4236
4746
  this.scrollAnimationTimeout = undefined;
@@ -4358,6 +4868,7 @@ export class AmLyrics extends LitElement {
4358
4868
  const targetTranslateY = paddingTop - gapTarget.offsetTop;
4359
4869
 
4360
4870
  this.isProgrammaticScroll = true;
4871
+ this.clearPastLineHighlights();
4361
4872
  this.animateScrollYouLy(targetTranslateY, false);
4362
4873
 
4363
4874
  setTimeout(() => {
@@ -4372,16 +4883,21 @@ export class AmLyrics extends LitElement {
4372
4883
  * Get the scroll padding top value from CSS variable
4373
4884
  */
4374
4885
  private getScrollPaddingTop(): number {
4886
+ if (this.cachedScrollPaddingTop !== null)
4887
+ return this.cachedScrollPaddingTop;
4375
4888
  if (!this.lyricsContainer) return 0;
4376
4889
  const style = getComputedStyle(this);
4377
4890
  const paddingTopValue =
4378
4891
  style.getPropertyValue('--lyrics-scroll-padding-top') || '25%';
4892
+ let result: number;
4379
4893
  if (paddingTopValue.includes('%')) {
4380
- return (
4381
- this.lyricsContainer.clientHeight * (parseFloat(paddingTopValue) / 100)
4382
- );
4894
+ result =
4895
+ this.lyricsContainer.clientHeight * (parseFloat(paddingTopValue) / 100);
4896
+ } else {
4897
+ result = parseFloat(paddingTopValue) || 0;
4383
4898
  }
4384
- return parseFloat(paddingTopValue) || 0;
4899
+ this.cachedScrollPaddingTop = result;
4900
+ return result;
4385
4901
  }
4386
4902
 
4387
4903
  /**
@@ -4394,6 +4910,7 @@ export class AmLyrics extends LitElement {
4394
4910
  ): void {
4395
4911
  if (!this.lyricsContainer) return;
4396
4912
  const parent = this.lyricsContainer;
4913
+ const targetTop = Math.max(0, -newTranslateY);
4397
4914
 
4398
4915
  if (!this.scrollAnimationState) {
4399
4916
  this.scrollAnimationState = {
@@ -4406,23 +4923,31 @@ export class AmLyrics extends LitElement {
4406
4923
  const animState = this.scrollAnimationState;
4407
4924
 
4408
4925
  if (animState.isAnimating && !forceScroll) {
4926
+ const pendingTop =
4927
+ animState.pendingUpdate === null
4928
+ ? null
4929
+ : Math.max(0, -animState.pendingUpdate);
4930
+ if (
4931
+ Math.abs(parent.scrollTop - targetTop) < 2 ||
4932
+ (pendingTop !== null && Math.abs(pendingTop - targetTop) < 2)
4933
+ ) {
4934
+ return;
4935
+ }
4409
4936
  animState.pendingUpdate = newTranslateY;
4410
4937
  return;
4411
4938
  }
4412
4939
 
4413
- if (this.scrollUnlockTimeout) {
4414
- clearTimeout(this.scrollUnlockTimeout);
4415
- this.scrollUnlockTimeout = undefined;
4416
- }
4417
-
4418
4940
  if (this.scrollAnimationTimeout) {
4419
4941
  clearTimeout(this.scrollAnimationTimeout);
4420
4942
  this.scrollAnimationTimeout = undefined;
4421
4943
  }
4944
+ if (this.scrollUnlockTimeout) {
4945
+ clearTimeout(this.scrollUnlockTimeout);
4946
+ this.scrollUnlockTimeout = undefined;
4947
+ }
4422
4948
 
4423
4949
  const { animatingLines } = this;
4424
4950
 
4425
- const targetTop = Math.max(0, -newTranslateY);
4426
4951
  const appliedTranslateY = -targetTop;
4427
4952
  const prevOffset = -parent.scrollTop;
4428
4953
  const delta = prevOffset - appliedTranslateY;
@@ -4439,7 +4964,6 @@ export class AmLyrics extends LitElement {
4439
4964
  // Clean up any lingering scroll animations before smooth scroll
4440
4965
  for (const line of animatingLines) {
4441
4966
  line.classList.remove('scroll-animate');
4442
- line.style.removeProperty('will-change');
4443
4967
  line.style.removeProperty('--scroll-delta');
4444
4968
  line.style.removeProperty('--lyrics-line-delay');
4445
4969
  line.style.removeProperty('--scroll-duration');
@@ -4451,15 +4975,23 @@ export class AmLyrics extends LitElement {
4451
4975
  return;
4452
4976
  }
4453
4977
 
4454
- // --- Step 1: Remove scroll-animate from ALL previously animating lines ---
4978
+ // --- Step 1: Remove scroll-animate and custom properties from ALL
4979
+ // previously animating lines so stale deltas don't interfere. ---
4455
4980
  for (const line of animatingLines) {
4456
4981
  line.classList.remove('scroll-animate');
4982
+ line.style.removeProperty('--scroll-delta');
4983
+ line.style.removeProperty('--lyrics-line-delay');
4984
+ line.style.removeProperty('--scroll-duration');
4457
4985
  }
4458
4986
  animatingLines.length = 0;
4459
4987
 
4460
- // Get lines for staggered animation
4461
- const lineElements = this.lyricsContainer.querySelectorAll('.lyrics-line');
4462
- const lineArray = Array.from(lineElements) as HTMLElement[];
4988
+ // Get lines for staggered animation — use cached array
4989
+ if (this.cachedLineArray.length === 0) {
4990
+ const lineElements =
4991
+ this.lyricsContainer.querySelectorAll('.lyrics-line');
4992
+ this.cachedLineArray = Array.from(lineElements) as HTMLElement[];
4993
+ }
4994
+ const lineArray = this.cachedLineArray;
4463
4995
 
4464
4996
  const referenceLine =
4465
4997
  this.currentPrimaryActiveLine ||
@@ -4471,58 +5003,83 @@ export class AmLyrics extends LitElement {
4471
5003
  const referenceIndex = lineArray.indexOf(referenceLine);
4472
5004
  if (referenceIndex === -1) return;
4473
5005
 
4474
- const delayIncrement = SCROLL_DELAY_INCREMENT_MS;
4475
- const lookBehind = 10;
4476
- const lookAhead = 15;
5006
+ const duration = Math.min(
5007
+ 450,
5008
+ scrollDuration ?? SCROLL_ANIMATION_DURATION_MS,
5009
+ );
5010
+ const delayIncrement = duration * 0.1;
5011
+ const lookAhead = 20;
4477
5012
  const len = lineArray.length;
4478
5013
 
4479
- const start = Math.max(0, referenceIndex - lookBehind);
5014
+ const start = Math.max(0, referenceIndex - lookAhead);
4480
5015
  const end = Math.min(len, referenceIndex + lookAhead);
4481
5016
 
4482
5017
  let maxAnimationDuration = 0;
4483
- let delayCounter = 0;
4484
-
4485
- // --- Step 2: Set CSS custom properties on target lines ---
4486
5018
  const newAnimatingLines: HTMLElement[] = [];
5019
+ const scrollingDown = delta >= 0;
5020
+
5021
+ if (scrollingDown) {
5022
+ let delayCounter = 0;
5023
+ for (let i = start; i < end; i += 1) {
5024
+ const line = lineArray[i];
5025
+ const delay = i >= referenceIndex ? delayCounter * delayIncrement : 0;
5026
+
5027
+ if (i >= referenceIndex && !line.classList.contains('lyrics-gap')) {
5028
+ delayCounter += 1;
5029
+ }
5030
+
5031
+ line.style.setProperty('--scroll-delta', `${delta}px`);
5032
+ line.style.setProperty('--lyrics-line-delay', `${delay}ms`);
5033
+ line.style.setProperty('--scroll-duration', `${duration + 100}ms`);
5034
+
5035
+ newAnimatingLines.push(line);
4487
5036
 
4488
- for (let i = start; i < end; i += 1) {
4489
- const line = lineArray[i];
4490
- if (i >= referenceIndex) delayCounter += 1;
4491
- const delay =
4492
- i >= referenceIndex ? (delayCounter - 1) * delayIncrement : 0;
5037
+ const lineDuration = duration + delay;
5038
+ if (lineDuration > maxAnimationDuration) {
5039
+ maxAnimationDuration = lineDuration;
5040
+ }
5041
+ }
5042
+ } else {
5043
+ let delayCounter = 0;
5044
+ for (let i = end - 1; i >= start; i -= 1) {
5045
+ const line = lineArray[i];
5046
+ const delay = i <= referenceIndex ? delayCounter * delayIncrement : 0;
4493
5047
 
4494
- const duration = scrollDuration ?? SCROLL_ANIMATION_DURATION_MS;
5048
+ if (i <= referenceIndex && !line.classList.contains('lyrics-gap')) {
5049
+ delayCounter += 1;
5050
+ }
4495
5051
 
4496
- line.style.setProperty('--scroll-delta', `${delta}px`);
4497
- line.style.setProperty('--lyrics-line-delay', `${delay}ms`);
4498
- line.style.setProperty('--scroll-duration', `${duration}ms`);
5052
+ line.style.setProperty('--scroll-delta', `${delta}px`);
5053
+ line.style.setProperty('--lyrics-line-delay', `${delay}ms`);
5054
+ line.style.setProperty('--scroll-duration', `${duration + 100}ms`);
4499
5055
 
4500
- newAnimatingLines.push(line);
5056
+ newAnimatingLines.push(line);
4501
5057
 
4502
- const lineDuration = duration + delay;
4503
- if (lineDuration > maxAnimationDuration) {
4504
- maxAnimationDuration = lineDuration;
5058
+ const lineDuration = duration + delay;
5059
+ if (lineDuration > maxAnimationDuration) {
5060
+ maxAnimationDuration = lineDuration;
5061
+ }
4505
5062
  }
4506
5063
  }
4507
5064
 
4508
5065
  // --- Step 3: Force reflow so the browser sees the class removal ---
4509
- // This guarantees the animation restarts reliably, unlike the
4510
- // CSS-variable-toggle approach which doesn't restart in all browsers.
4511
- parent.getBoundingClientRect(); // force synchronous reflow
5066
+ // Use offsetHeight which is cheaper than getBoundingClientRect
5067
+ // eslint-disable-next-line no-void
5068
+ void parent.offsetHeight;
4512
5069
 
4513
5070
  // --- Step 4: Re-add scroll-animate class to start fresh animations ---
4514
5071
  for (const line of newAnimatingLines) {
4515
5072
  line.classList.add('scroll-animate');
4516
- line.style.willChange = 'transform';
4517
5073
  animatingLines.push(line);
4518
5074
  }
4519
5075
 
4520
5076
  animState.isAnimating = true;
4521
- const BASE_DURATION = scrollDuration ?? SCROLL_ANIMATION_DURATION_MS;
4522
5077
 
5078
+ // YouLyPlus-style early unlock: allow new scrolls to start after a
5079
+ // short base duration, even if CSS animations are still running.
5080
+ const BASE_DURATION = 400;
4523
5081
  this.scrollUnlockTimeout = setTimeout(() => {
4524
5082
  animState.isAnimating = false;
4525
-
4526
5083
  if (animState.pendingUpdate !== null) {
4527
5084
  const pendingValue = animState.pendingUpdate;
4528
5085
  animState.pendingUpdate = null;
@@ -4534,7 +5091,6 @@ export class AmLyrics extends LitElement {
4534
5091
  for (let i = 0; i < animatingLines.length; i += 1) {
4535
5092
  const line = animatingLines[i];
4536
5093
  line.classList.remove('scroll-animate');
4537
- line.style.removeProperty('will-change');
4538
5094
  line.style.removeProperty('--scroll-delta');
4539
5095
  line.style.removeProperty('--lyrics-line-delay');
4540
5096
  line.style.removeProperty('--scroll-duration');
@@ -4576,10 +5132,14 @@ export class AmLyrics extends LitElement {
4576
5132
  lineToScroll.classList.add('lyrics-activest');
4577
5133
  this.positionedLineElements.push(lineToScroll);
4578
5134
 
4579
- const lineElements = Array.from(
4580
- this.lyricsContainer.querySelectorAll('.lyrics-line'),
4581
- ) as HTMLElement[];
5135
+ if (this.cachedLineArray.length === 0) {
5136
+ this.cachedLineArray = Array.from(
5137
+ this.lyricsContainer.querySelectorAll('.lyrics-line'),
5138
+ ) as HTMLElement[];
5139
+ }
5140
+ const lineElements = this.cachedLineArray;
4582
5141
  const scrollLineIndex = lineElements.indexOf(lineToScroll);
5142
+ if (scrollLineIndex === -1) return;
4583
5143
 
4584
5144
  for (
4585
5145
  let i = Math.max(0, scrollLineIndex - 4);
@@ -4646,6 +5206,8 @@ export class AmLyrics extends LitElement {
4646
5206
  this.userScrollTimeoutId = undefined;
4647
5207
  }
4648
5208
 
5209
+ this.clearPastLineHighlights();
5210
+
4649
5211
  const duration = scrollDuration ?? SCROLL_ANIMATION_DURATION_MS;
4650
5212
  setTimeout(() => {
4651
5213
  this.isProgrammaticScroll = false;
@@ -4674,6 +5236,7 @@ export class AmLyrics extends LitElement {
4674
5236
  ? (Array.from(wordElement.querySelectorAll('span.char')) as HTMLElement[])
4675
5237
  : [];
4676
5238
  const isGrowable = wordElement?.classList.contains('growable');
5239
+ const isCharRise = wordElement?.classList.contains('char-rise');
4677
5240
  const isFirstSyllable =
4678
5241
  syllable.getAttribute('data-syllable-index') === '0';
4679
5242
  const isFirstInContainer = isFirstSyllable; // Simplified
@@ -4742,6 +5305,21 @@ export class AmLyrics extends LitElement {
4742
5305
  });
4743
5306
  }
4744
5307
 
5308
+ if (isCharRise && isFirstSyllable && allWordCharSpans.length > 0) {
5309
+ const finalDuration = Math.max(wordDurationMs, syllableDurationMs);
5310
+ const baseDelayPerChar = finalDuration * 0.09;
5311
+ const riseDurationMs = finalDuration * 1.5;
5312
+
5313
+ allWordCharSpans.forEach(span => {
5314
+ const charIndex = parseFloat(span.dataset.syllableCharIndex || '0');
5315
+ const riseDelay = baseDelayPerChar * charIndex;
5316
+ charAnimationsMap.set(
5317
+ span,
5318
+ `rise-char ${riseDurationMs}ms ease-in-out ${riseDelay}ms forwards`,
5319
+ );
5320
+ });
5321
+ }
5322
+
4745
5323
  // Step 2: Wipe Pass
4746
5324
  if (charSpans.length > 0) {
4747
5325
  charSpans.forEach((span, charIndex) => {
@@ -4764,7 +5342,11 @@ export class AmLyrics extends LitElement {
4764
5342
 
4765
5343
  const animationParts = [];
4766
5344
 
4767
- if (existingAnimation && existingAnimation.includes('grow-dynamic')) {
5345
+ if (
5346
+ existingAnimation &&
5347
+ (existingAnimation.includes('grow-dynamic') ||
5348
+ existingAnimation.includes('rise-char'))
5349
+ ) {
4768
5350
  animationParts.push(existingAnimation.split(',')[0].trim());
4769
5351
  }
4770
5352
  if (charIndex > 0) {
@@ -4846,13 +5428,13 @@ export class AmLyrics extends LitElement {
4846
5428
  syllable.style.backgroundColor = 'var(--lyplus-text-secondary)';
4847
5429
 
4848
5430
  // Reset character animations — disable transition so finished chars don't slowly fade
4849
- syllable.querySelectorAll('span.char').forEach(span => {
4850
- const el = span as HTMLElement;
5431
+ const charSpans = syllable.querySelectorAll('span.char');
5432
+ for (let i = 0; i < charSpans.length; i += 1) {
5433
+ const el = charSpans[i] as HTMLElement;
4851
5434
  el.style.animation = '';
4852
- el.style.willChange = '';
4853
5435
  el.style.transition = 'none';
4854
5436
  el.style.backgroundColor = 'var(--lyplus-text-secondary)';
4855
- });
5437
+ }
4856
5438
 
4857
5439
  // Immediately remove all state classes
4858
5440
  syllable.classList.remove(
@@ -4861,30 +5443,124 @@ export class AmLyrics extends LitElement {
4861
5443
  'pre-highlight',
4862
5444
  'cleanup',
4863
5445
  );
4864
-
4865
- // In next frame, clear inline styles so CSS transitions can resume for future use
4866
- requestAnimationFrame(() => {
4867
- syllable.style.removeProperty('background-color');
4868
- syllable.style.removeProperty('transition');
4869
- syllable.querySelectorAll('span.char').forEach(span => {
4870
- const el = span as HTMLElement;
4871
- el.style.removeProperty('background-color');
4872
- el.style.removeProperty('transition');
4873
- el.style.removeProperty('will-change');
4874
- });
4875
- });
4876
5446
  }
4877
5447
 
4878
5448
  /**
4879
- * Reset all syllables in a line
5449
+ * Reset all syllables in a line — batches deferred cleanup into a single rAF
4880
5450
  */
4881
5451
  private static resetSyllables(line: HTMLElement): void {
4882
5452
  if (!line) return;
5453
+ line.classList.remove('persist-highlight');
4883
5454
  // eslint-disable-next-line no-param-reassign
4884
5455
  (line as any)._cachedSyllableElements = null;
4885
- Array.from(line.getElementsByClassName('lyrics-syllable')).forEach(
4886
- syllable => AmLyrics.resetSyllable(syllable as HTMLElement),
4887
- );
5456
+ const syllables = line.getElementsByClassName('lyrics-syllable');
5457
+ for (let i = 0; i < syllables.length; i += 1) {
5458
+ AmLyrics.resetSyllable(syllables[i] as HTMLElement);
5459
+ }
5460
+ // Batch deferred style cleanup into a single rAF for all syllables in the line
5461
+ requestAnimationFrame(() => {
5462
+ for (let i = 0; i < syllables.length; i += 1) {
5463
+ const syllable = syllables[i] as HTMLElement;
5464
+ syllable.style.removeProperty('background-color');
5465
+ syllable.style.removeProperty('transition');
5466
+ const chars = syllable.querySelectorAll('span.char');
5467
+ for (let j = 0; j < chars.length; j += 1) {
5468
+ const el = chars[j] as HTMLElement;
5469
+ el.style.removeProperty('background-color');
5470
+ el.style.removeProperty('transition');
5471
+ el.style.removeProperty('will-change');
5472
+ }
5473
+ }
5474
+ });
5475
+ }
5476
+
5477
+ /**
5478
+ * Gentle reset for normal playback: remove highlight/finished classes
5479
+ * without forcing inline styles. Lets CSS transition fade syllables
5480
+ * back to secondary colour smoothly.
5481
+ */
5482
+ private static unfinishSyllables(line: HTMLElement): void {
5483
+ if (!line) return;
5484
+ line.classList.remove('persist-highlight');
5485
+ const syllables = line.getElementsByClassName('lyrics-syllable');
5486
+ for (let i = 0; i < syllables.length; i += 1) {
5487
+ const s = syllables[i] as HTMLElement;
5488
+ s.classList.remove('highlight', 'finished', 'pre-highlight', 'cleanup');
5489
+ s.style.animation = '';
5490
+ s.style.removeProperty('--pre-wipe-duration');
5491
+ s.style.removeProperty('--pre-wipe-delay');
5492
+ s.style.removeProperty('background-color');
5493
+ s.style.removeProperty('transition');
5494
+ const chars = s.querySelectorAll('span.char');
5495
+ for (let j = 0; j < chars.length; j += 1) {
5496
+ const el = chars[j] as HTMLElement;
5497
+ el.style.animation = '';
5498
+ el.style.removeProperty('will-change');
5499
+ el.style.removeProperty('background-color');
5500
+ el.style.removeProperty('transition');
5501
+ el.style.removeProperty('filter');
5502
+ }
5503
+ }
5504
+ }
5505
+
5506
+ private static finishSyllablesUpToTime(
5507
+ line: HTMLElement,
5508
+ currentTimeMs: number,
5509
+ ): void {
5510
+ if (!line) return;
5511
+ let hasFinishedSyllable = false;
5512
+
5513
+ let syllables: HTMLElement[] = (line as any)._cachedSyllableElements;
5514
+ if (!syllables) {
5515
+ syllables = Array.from(
5516
+ line.querySelectorAll('.lyrics-syllable'),
5517
+ ) as HTMLElement[];
5518
+ for (let i = 0; i < syllables.length; i += 1) {
5519
+ const syllable = syllables[i];
5520
+ (syllable as any)._cachedStartTime = parseFloat(
5521
+ syllable.getAttribute('data-start-time') || '0',
5522
+ );
5523
+ (syllable as any)._cachedEndTime = parseFloat(
5524
+ syllable.getAttribute('data-end-time') || '0',
5525
+ );
5526
+ }
5527
+ // eslint-disable-next-line no-param-reassign
5528
+ (line as any)._cachedSyllableElements = syllables;
5529
+ }
5530
+
5531
+ for (let i = 0; i < syllables.length; i += 1) {
5532
+ const syllable = syllables[i];
5533
+ const startTime = (syllable as any)._cachedStartTime;
5534
+ if (Number.isFinite(startTime) && currentTimeMs >= startTime) {
5535
+ const { classList } = syllable;
5536
+ if (!classList.contains('finished')) {
5537
+ if (!classList.contains('highlight')) {
5538
+ AmLyrics.updateSyllableAnimation(
5539
+ syllable,
5540
+ Math.max(0, currentTimeMs - startTime),
5541
+ );
5542
+ }
5543
+ classList.add('finished');
5544
+ }
5545
+ hasFinishedSyllable = true;
5546
+ classList.remove('highlight');
5547
+ classList.remove('pre-highlight');
5548
+ classList.add('cleanup');
5549
+ syllable.style.animation = '';
5550
+ syllable.style.removeProperty('--pre-wipe-duration');
5551
+ syllable.style.removeProperty('--pre-wipe-delay');
5552
+ const chars = syllable.querySelectorAll('span.char');
5553
+ for (let ci = 0; ci < chars.length; ci += 1) {
5554
+ (chars[ci] as HTMLElement).style.animation = '';
5555
+ }
5556
+ }
5557
+ }
5558
+
5559
+ if (hasFinishedSyllable) {
5560
+ line.classList.add('persist-highlight');
5561
+ } else {
5562
+ line.classList.remove('persist-highlight');
5563
+ }
4888
5564
  }
4889
5565
 
4890
5566
  /**
@@ -4901,16 +5577,23 @@ export class AmLyrics extends LitElement {
4901
5577
  syllables = Array.from(
4902
5578
  line.querySelectorAll('.lyrics-syllable'),
4903
5579
  ) as HTMLElement[];
5580
+ for (let i = 0; i < syllables.length; i += 1) {
5581
+ const syllable = syllables[i];
5582
+ (syllable as any)._cachedStartTime = parseFloat(
5583
+ syllable.getAttribute('data-start-time') || '0',
5584
+ );
5585
+ (syllable as any)._cachedEndTime = parseFloat(
5586
+ syllable.getAttribute('data-end-time') || '0',
5587
+ );
5588
+ }
4904
5589
  // eslint-disable-next-line no-param-reassign
4905
5590
  (line as any)._cachedSyllableElements = syllables;
4906
5591
  }
4907
5592
 
4908
5593
  for (let i = 0; i < syllables.length; i += 1) {
4909
5594
  const syllable = syllables[i];
4910
- const startTime = parseFloat(
4911
- syllable.getAttribute('data-start-time') || '0',
4912
- );
4913
- const endTime = parseFloat(syllable.getAttribute('data-end-time') || '0');
5595
+ const startTime = (syllable as any)._cachedStartTime;
5596
+ const endTime = (syllable as any)._cachedEndTime;
4914
5597
 
4915
5598
  if (Number.isFinite(startTime) && Number.isFinite(endTime)) {
4916
5599
  const { classList } = syllable;
@@ -4957,6 +5640,7 @@ export class AmLyrics extends LitElement {
4957
5640
  );
4958
5641
  }
4959
5642
  classList.add('finished');
5643
+ // Keep the completed wipe state until user scroll resets it.
4960
5644
  }
4961
5645
  } else if (hasHighlight || hasFinished) {
4962
5646
  // Not yet started
@@ -5286,47 +5970,49 @@ export class AmLyrics extends LitElement {
5286
5970
  // Create background vocals container (with romanization support)
5287
5971
  const backgroundVocalElement = hasBackground
5288
5972
  ? html`<p class="background-vocal-container">
5289
- ${line.backgroundText!.map((syllable, syllableIndex) => {
5290
- const startTimeMs = syllable.timestamp;
5291
- const endTimeMs = syllable.endtime;
5292
- const durationMs = endTimeMs - startTimeMs;
5293
-
5294
- const bgRomanizedText =
5295
- this.showRomanization &&
5296
- syllable.romanizedText &&
5297
- syllable.romanizedText.trim() !== syllable.text.trim()
5298
- ? html`<span
5299
- class="lyrics-syllable transliteration no-chars ${syllable.lineSynced
5300
- ? 'line-synced'
5973
+ <span class="background-vocal-wrap">
5974
+ ${line.backgroundText!.map((syllable, syllableIndex) => {
5975
+ const startTimeMs = syllable.timestamp;
5976
+ const endTimeMs = syllable.endtime;
5977
+ const durationMs = endTimeMs - startTimeMs;
5978
+
5979
+ const bgRomanizedText =
5980
+ this.showRomanization &&
5981
+ syllable.romanizedText &&
5982
+ syllable.romanizedText.trim() !== syllable.text.trim()
5983
+ ? html`<span
5984
+ class="lyrics-syllable transliteration no-chars ${syllable.lineSynced
5985
+ ? 'line-synced'
5986
+ : ''}"
5987
+ data-start-time="${startTimeMs}"
5988
+ data-end-time="${endTimeMs}"
5989
+ data-duration="${durationMs}"
5990
+ data-syllable-index="0"
5991
+ data-wipe-ratio="1"
5992
+ >${syllable.romanizedText}</span
5993
+ >`
5994
+ : '';
5995
+
5996
+ return html`<span class="lyrics-word"
5997
+ ><span
5998
+ class="lyrics-syllable-wrap${bgRomanizedText
5999
+ ? ' has-transliteration'
6000
+ : ''}"
6001
+ ><span
6002
+ class="lyrics-syllable no-chars${syllable.lineSynced
6003
+ ? ' line-synced'
5301
6004
  : ''}"
5302
6005
  data-start-time="${startTimeMs}"
5303
6006
  data-end-time="${endTimeMs}"
5304
6007
  data-duration="${durationMs}"
5305
- data-syllable-index="0"
6008
+ data-syllable-index="${syllableIndex}"
5306
6009
  data-wipe-ratio="1"
5307
- >${syllable.romanizedText}</span
5308
- >`
5309
- : '';
5310
-
5311
- return html`<span class="lyrics-word"
5312
- ><span
5313
- class="lyrics-syllable-wrap${bgRomanizedText
5314
- ? ' has-transliteration'
5315
- : ''}"
5316
- ><span
5317
- class="lyrics-syllable no-chars${syllable.lineSynced
5318
- ? ' line-synced'
5319
- : ''}"
5320
- data-start-time="${startTimeMs}"
5321
- data-end-time="${endTimeMs}"
5322
- data-duration="${durationMs}"
5323
- data-syllable-index="${syllableIndex}"
5324
- data-wipe-ratio="1"
5325
- >${syllable.text}</span
5326
- >${bgRomanizedText}</span
5327
- ></span
5328
- >`;
5329
- })}
6010
+ >${syllable.text}</span
6011
+ >${bgRomanizedText}</span
6012
+ ></span
6013
+ >`;
6014
+ })}
6015
+ </span>
5330
6016
  </p>`
5331
6017
  : '';
5332
6018
 
@@ -5343,6 +6029,7 @@ export class AmLyrics extends LitElement {
5343
6029
  const wordGroups = lineData?.wordGroups ?? [];
5344
6030
  const groupGrowable = lineData?.groupGrowable ?? [];
5345
6031
  const groupGlowing = lineData?.groupGlowing ?? [];
6032
+ const groupCharRise = lineData?.groupCharRise ?? [];
5346
6033
  const vwFullText = lineData?.vwFullText ?? [];
5347
6034
  const vwFullDuration = lineData?.vwFullDuration ?? [];
5348
6035
  const vwCharOffset = lineData?.vwCharOffset ?? [];
@@ -5355,12 +6042,18 @@ export class AmLyrics extends LitElement {
5355
6042
  ${wordGroups.map((group, groupIdx) => {
5356
6043
  const isGrowable = groupGrowable[groupIdx];
5357
6044
  const isGlowing = groupGlowing[groupIdx];
6045
+ const isCharRise = groupCharRise[groupIdx];
6046
+ const isAnimatedByChar = isGrowable || isCharRise;
5358
6047
  const groupLineSynced = group.some(s => s.lineSynced);
5359
6048
 
5360
- const wordText = isGrowable ? vwFullText[groupIdx] : '';
5361
- const wordDuration = isGrowable ? vwFullDuration[groupIdx] : 0;
6049
+ const wordText = isAnimatedByChar ? vwFullText[groupIdx] : '';
6050
+ const wordDuration = isAnimatedByChar
6051
+ ? vwFullDuration[groupIdx]
6052
+ : 0;
5362
6053
  const wordNumChars = wordText.length;
5363
- const groupCharOffset = isGrowable ? vwCharOffset[groupIdx] : 0;
6054
+ const groupCharOffset = isAnimatedByChar
6055
+ ? vwCharOffset[groupIdx]
6056
+ : 0;
5364
6057
 
5365
6058
  let sylCharAccumulator = 0;
5366
6059
 
@@ -5382,9 +6075,11 @@ export class AmLyrics extends LitElement {
5382
6075
  );
5383
6076
 
5384
6077
  return html`<span
5385
- class="lyrics-word${isGrowable ? ' growable' : ''}${isGlowing
5386
- ? ' glowing'
5387
- : ''}${shouldAllowBreak ? ' allow-break' : ''}"
6078
+ class="lyrics-word${isGrowable ? ' growable' : ''}${isCharRise
6079
+ ? ' char-rise'
6080
+ : ''}${isGlowing ? ' glowing' : ''}${shouldAllowBreak
6081
+ ? ' allow-break'
6082
+ : ''}"
5388
6083
  style="--rise-duration: ${riseDuration}s"
5389
6084
  >${group.map((syllable, sylIdx) => {
5390
6085
  const startTimeMs = syllable.timestamp;
@@ -5411,7 +6106,7 @@ export class AmLyrics extends LitElement {
5411
6106
 
5412
6107
  let syllableContent: any = text;
5413
6108
 
5414
- if (isGrowable) {
6109
+ if (isAnimatedByChar) {
5415
6110
  let charIndexInsideSyllable = 0;
5416
6111
  const numCharsInSyllable =
5417
6112
  text.replace(/\s/g, '').length || 1;
@@ -5519,7 +6214,7 @@ export class AmLyrics extends LitElement {
5519
6214
  ><span
5520
6215
  class="lyrics-syllable${groupLineSynced
5521
6216
  ? ' line-synced'
5522
- : ''}${isGrowable ? ' has-chars' : ' no-chars'}"
6217
+ : ''}${isAnimatedByChar ? ' has-chars' : ' no-chars'}"
5523
6218
  data-start-time="${startTimeMs}"
5524
6219
  data-end-time="${endTimeMs}"
5525
6220
  data-duration="${durationMs}"