@uimaxbai/am-lyrics 1.2.9 → 1.4.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,7 +2,7 @@ import { css, html, LitElement, svg } from 'lit';
2
2
  import { property, query, state } from 'lit/decorators.js';
3
3
  import { GoogleService } from './GoogleService.js';
4
4
 
5
- const VERSION = '1.2.9';
5
+ const VERSION = '1.4.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;
@@ -34,7 +34,6 @@ function fetchWithTimeout(
34
34
 
35
35
  const KPOE_SERVERS = [
36
36
  'https://lyricsplus.binimum.org',
37
- 'https://lyricsplus.atomix.one',
38
37
  'https://lyricsplus-seven.vercel.app',
39
38
  'https://lyricsplus.prjktla.workers.dev',
40
39
  'https://lyrics-plus-backend.vercel.app',
@@ -42,19 +41,6 @@ const KPOE_SERVERS = [
42
41
  const DEFAULT_KPOE_SOURCE_ORDER =
43
42
  'apple,lyricsplus,musixmatch,spotify,qq,deezer,musixmatch-word';
44
43
 
45
- const TIDAL_SERVERS = [
46
- 'https://arran.monochrome.tf',
47
- 'https://api.monochrome.tf/',
48
- 'https://triton.squid.wtf',
49
- 'https://wolf.qqdl.site',
50
- 'https://maus.qqdl.site',
51
- 'https://vogel.qqdl.site',
52
- 'https://katze.qqdl.site',
53
- 'https://hund.qqdl.site',
54
- 'https://tidal.kinoplus.online',
55
- 'https://hifi-one.spotisaver.net',
56
- 'https://hifi-two.spotisaver.net',
57
- ];
58
44
  const GENIUS_WORKER_URL = 'https://fetch-genius.samidy.workers.dev/';
59
45
 
60
46
  interface Syllable {
@@ -85,6 +71,7 @@ interface SongMetadata {
85
71
  artist: string;
86
72
  album?: string;
87
73
  durationMs?: number;
74
+ songwriters?: string;
88
75
  }
89
76
 
90
77
  interface SongCatalogResult {
@@ -92,6 +79,7 @@ interface SongCatalogResult {
92
79
  artist?: string;
93
80
  album?: string;
94
81
  durationMs?: number;
82
+ songwriters?: string;
95
83
  id?: {
96
84
  appleMusic?: string;
97
85
  [key: string]: unknown;
@@ -108,6 +96,7 @@ interface ParsedQueryMetadata {
108
96
  interface YouLyPlusLyricsResult {
109
97
  lines: LyricsLine[];
110
98
  source: string;
99
+ songwriters?: string;
111
100
  }
112
101
 
113
102
  interface ResolvedMetadata {
@@ -145,6 +134,7 @@ export class AmLyrics extends LitElement {
145
134
  --lyplus-font-size-base: 32px;
146
135
  --lyplus-font-size-base-grow: 24.5;
147
136
  --lyplus-font-size-subtext: 0.6em;
137
+ --char-rise-y: calc(-0.035 * var(--lyplus-font-size-base));
148
138
 
149
139
  --lyplus-blur-amount: 0.07em;
150
140
  --lyplus-blur-amount-near: 0.035em;
@@ -178,7 +168,6 @@ export class AmLyrics extends LitElement {
178
168
  -webkit-overflow-scrolling: touch;
179
169
  box-sizing: border-box;
180
170
  scrollbar-width: none;
181
- transform: translateZ(0);
182
171
  }
183
172
 
184
173
  .lyrics-container::-webkit-scrollbar {
@@ -189,11 +178,13 @@ export class AmLyrics extends LitElement {
189
178
  .lyrics-container.touch-scrolling .lyrics-line,
190
179
  .lyrics-container.touch-scrolling .lyrics-plus-metadata {
191
180
  transition: none !important;
181
+ filter: none !important;
192
182
  }
193
183
 
194
184
  /* Apply smooth gliding transition for mouse-wheel scrolling */
195
185
  .lyrics-container.wheel-scrolling .lyrics-line {
196
186
  transition: transform 0.3s ease-out !important;
187
+ filter: none !important;
197
188
  }
198
189
 
199
190
  .lyrics-line.scroll-animate {
@@ -220,18 +211,13 @@ export class AmLyrics extends LitElement {
220
211
  font-size: var(--lyplus-font-size-base);
221
212
  cursor: pointer;
222
213
  transform-origin: left;
223
- transform: translateZ(1px);
224
214
  transition:
225
215
  opacity 0.3s ease,
226
216
  transform 0.4s cubic-bezier(0.41, 0, 0.12, 0.99)
227
217
  var(--lyrics-line-delay, 0ms),
228
218
  filter 0.3s ease;
229
- will-change: transform, filter, opacity;
230
219
  content-visibility: auto;
231
220
  text-rendering: optimizeLegibility;
232
- overflow-wrap: break-word;
233
- mix-blend-mode: lighten;
234
- border-radius: var(--lyplus-border-radius-base);
235
221
  }
236
222
 
237
223
  .lyrics-line:not(.scroll-animate) {
@@ -251,8 +237,7 @@ export class AmLyrics extends LitElement {
251
237
 
252
238
  .lyrics-line.active .lyrics-line-container,
253
239
  .lyrics-line.pre-active .lyrics-line-container {
254
- transform: scale3d(1.001, 1.001, 1);
255
- will-change: transform;
240
+ transform: scale3d(1.001, 1.001, 1) translateZ(0);
256
241
  transition:
257
242
  transform 0.5s ease,
258
243
  background-color 0.18s,
@@ -297,12 +282,10 @@ export class AmLyrics extends LitElement {
297
282
  .lyrics-line.active {
298
283
  opacity: 1;
299
284
  color: var(--lyplus-text-primary);
300
- will-change: transform, opacity;
301
285
  }
302
286
 
303
287
  .lyrics-line.pre-active {
304
288
  opacity: 1;
305
- will-change: transform, opacity;
306
289
  }
307
290
 
308
291
  .lyrics-line.singer-right {
@@ -316,6 +299,18 @@ export class AmLyrics extends LitElement {
316
299
 
317
300
  .lyrics-line.rtl-text {
318
301
  direction: rtl;
302
+ text-align: right !important;
303
+ transform-origin: right;
304
+ }
305
+
306
+ .lyrics-line.rtl-text .lyrics-line-container,
307
+ .lyrics-line.rtl-text .main-vocal-container {
308
+ transform-origin: right;
309
+ }
310
+
311
+ .lyrics-line.rtl-text .lyrics-romanization-container,
312
+ .lyrics-line.rtl-text .lyrics-translation-container {
313
+ text-align: right;
319
314
  }
320
315
 
321
316
  /* --- Unsynced (Plain Text) Lyrics Overrides --- */
@@ -347,7 +342,8 @@ export class AmLyrics extends LitElement {
347
342
 
348
343
  @media (hover: hover) and (pointer: fine) {
349
344
  .lyrics-line:hover {
350
- background: var(--hover-background-color, rgba(255, 255, 255, 0.13));
345
+ filter: none !important;
346
+ opacity: 1 !important;
351
347
  }
352
348
  .lyrics-container.is-unsynced .lyrics-line:hover {
353
349
  background: transparent !important;
@@ -377,6 +373,7 @@ export class AmLyrics extends LitElement {
377
373
 
378
374
  /* Unblur all lines when user is scrolling */
379
375
  .lyrics-container.user-scrolling .lyrics-line {
376
+ transition: none !important;
380
377
  filter: none !important;
381
378
  opacity: 0.8 !important;
382
379
  }
@@ -393,6 +390,7 @@ export class AmLyrics extends LitElement {
393
390
  .lyrics-word:not(.allow-break) {
394
391
  display: inline-block;
395
392
  vertical-align: baseline;
393
+ white-space: nowrap;
396
394
  }
397
395
 
398
396
  .lyrics-word.allow-break {
@@ -403,7 +401,7 @@ export class AmLyrics extends LitElement {
403
401
  display: inline;
404
402
  }
405
403
 
406
- .lyrics-syllable-wrap:has(.lyrics-syllable.transliteration) {
404
+ .lyrics-syllable-wrap.has-transliteration {
407
405
  display: inline-flex;
408
406
  flex-direction: column;
409
407
  align-items: start;
@@ -431,7 +429,7 @@ export class AmLyrics extends LitElement {
431
429
  transition: transform 1s ease !important;
432
430
  }
433
431
 
434
- .lyrics-syllable.finished:has(.char) {
432
+ .lyrics-syllable.finished.has-chars {
435
433
  background-color: transparent;
436
434
  }
437
435
 
@@ -440,19 +438,16 @@ export class AmLyrics extends LitElement {
440
438
  }
441
439
 
442
440
  .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable {
443
- transform: translateY(0.001%) translateZ(1px);
444
441
  transition:
445
442
  transform 1s ease,
446
443
  background-color 0.5s,
447
444
  color 0.5s;
448
- will-change: transform, background;
449
445
  }
450
446
 
451
447
  /* --- Wipe Highlight Effect --- */
448
+ .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.highlight.no-chars,
452
449
  .lyrics-line.active:not(.lyrics-gap)
453
- .lyrics-syllable.highlight:not(:has(.char)),
454
- .lyrics-line.active:not(.lyrics-gap)
455
- .lyrics-syllable.pre-highlight:not(:has(.char)) {
450
+ .lyrics-syllable.pre-highlight.no-chars {
456
451
  background-repeat: no-repeat;
457
452
  background-image:
458
453
  linear-gradient(
@@ -494,11 +489,19 @@ export class AmLyrics extends LitElement {
494
489
  right;
495
490
  }
496
491
 
492
+ /* Non-growable words float up with a gentle curve */
497
493
  .lyrics-line.active:not(.lyrics-gap)
498
494
  .lyrics-word:not(.growable)
499
- .lyrics-syllable.highlight,
495
+ .lyrics-syllable.highlight {
496
+ 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;
501
+ }
502
+
500
503
  .lyrics-word.growable .lyrics-syllable.cleanup .char {
501
- transform: translateY(-3.5%) translateZ(1px);
504
+ transform: translateY(-3.5%);
502
505
  }
503
506
 
504
507
  .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.highlight.finished {
@@ -525,7 +528,7 @@ export class AmLyrics extends LitElement {
525
528
  }
526
529
 
527
530
  /* Syllable with chars: make syllable transparent, chars handle color */
528
- .lyrics-line .lyrics-syllable:has(span.char):not(.finished) {
531
+ .lyrics-line .lyrics-syllable.has-chars:not(.finished) {
529
532
  background-color: transparent;
530
533
  color: transparent;
531
534
  }
@@ -538,6 +541,7 @@ export class AmLyrics extends LitElement {
538
541
  font-feature-settings: 'liga' 0;
539
542
  background-clip: text;
540
543
  -webkit-background-clip: text;
544
+ backface-visibility: hidden;
541
545
  transition:
542
546
  color 0.7s,
543
547
  background-color 0.7s,
@@ -573,11 +577,9 @@ export class AmLyrics extends LitElement {
573
577
  -0.5em 0%,
574
578
  -0.25em 0%;
575
579
  transform-origin: 50% 80%;
576
- transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
577
580
  transition:
578
581
  transform 0.7s ease,
579
582
  color 0.18s;
580
- will-change: background, transform;
581
583
  }
582
584
 
583
585
  .lyrics-line.active .lyrics-syllable span.char.highlight {
@@ -629,6 +631,8 @@ export class AmLyrics extends LitElement {
629
631
  box-sizing: content-box;
630
632
  background-clip: unset;
631
633
  transform-origin: top;
634
+ content-visibility: visible !important;
635
+ contain: none !important;
632
636
  transition:
633
637
  opacity 160ms ease-out,
634
638
  transform var(--scroll-duration, 280ms) var(--lyrics-line-delay, 0ms);
@@ -639,41 +643,35 @@ export class AmLyrics extends LitElement {
639
643
  transition:
640
644
  opacity 160ms ease-out,
641
645
  transform var(--scroll-duration, 280ms);
642
- will-change: opacity;
643
646
  }
644
647
 
645
648
  /* Exiting state: quickly collapse width and height so dots don't distort page, or remove max-height transition */
646
649
  .lyrics-gap.gap-exiting {
647
650
  opacity: 1;
648
- transition: transform var(--scroll-duration, 280ms);
649
651
  }
650
652
 
651
653
  .lyrics-gap .main-vocal-container {
652
- transform: translateY(-25%) scale(1) translateZ(0);
654
+ transform: translateY(-25%) scale(1);
653
655
  transition: transform 400ms cubic-bezier(0.22, 1, 0.36, 1);
654
656
  }
655
657
 
656
- /* Jump animation plays during exit */
657
- .lyrics-gap.gap-exiting .main-vocal-container {
658
- animation: gap-ended var(--gap-exit-duration, 360ms)
659
- cubic-bezier(0.33, 1, 0.68, 1) forwards;
660
- }
661
-
662
658
  .lyrics-gap:not(.active):not(.gap-exiting) .main-vocal-container {
663
- transform: translateY(-25%) scale(0) translateZ(0);
659
+ transform: translateY(-25%) scale(0);
664
660
  }
665
661
 
666
- .lyrics-gap:not(.active):not(.gap-exiting)
667
- .main-vocal-container
668
- .lyrics-word {
669
- animation-play-state: paused;
670
- }
671
-
672
- .lyrics-gap.active .main-vocal-container .lyrics-word {
662
+ /* Pulse — must come BEFORE .gap-exiting so exiting wins via specificity+order */
663
+ .lyrics-gap.active .main-vocal-container {
673
664
  animation: gap-loop var(--gap-pulse-duration, 4000ms) ease-in-out infinite
674
665
  alternate;
675
666
  animation-delay: var(--gap-loop-delay, 0ms);
676
- will-change: transform;
667
+ }
668
+
669
+ /* Jump animation plays during exit — disable transition so animation wins.
670
+ Placed AFTER .active so it wins when both classes are present briefly. */
671
+ .lyrics-gap.gap-exiting .main-vocal-container {
672
+ animation: gap-ended var(--gap-exit-duration, 360ms)
673
+ cubic-bezier(0.33, 1, 0.68, 1) forwards;
674
+ transition: none !important;
677
675
  }
678
676
 
679
677
  .lyrics-gap .lyrics-syllable {
@@ -724,20 +722,17 @@ export class AmLyrics extends LitElement {
724
722
  background-clip: unset;
725
723
  }
726
724
 
727
- .lyrics-gap.active .lyrics-syllable.highlight,
728
725
  .lyrics-gap.active .lyrics-syllable.finished,
729
- .lyrics-gap.gap-exiting .lyrics-syllable,
730
- .lyrics-gap:not(.active).post-active-line .lyrics-syllable,
731
- .lyrics-gap:not(.active).lyrics-activest .lyrics-syllable {
726
+ .lyrics-gap.gap-exiting .lyrics-syllable.finished,
727
+ .lyrics-gap:not(.active):not(.gap-exiting).post-active-line
728
+ .lyrics-syllable,
729
+ .lyrics-gap:not(.active):not(.gap-exiting).lyrics-activest
730
+ .lyrics-syllable {
732
731
  background-color: var(--lyplus-text-primary);
733
732
  animation: none !important;
734
733
  opacity: 1;
735
734
  }
736
735
 
737
- .lyrics-gap.active .lyrics-syllable.finished {
738
- animation: none !important;
739
- }
740
-
741
736
  /* ==========================================================================
742
737
  METADATA & FOOTER STYLES
743
738
  ========================================================================== */
@@ -766,12 +761,49 @@ export class AmLyrics extends LitElement {
766
761
  align-items: center;
767
762
  flex-wrap: wrap;
768
763
  text-align: left;
769
- font-size: 0.8em;
770
- color: rgba(255, 255, 255, 0.5);
771
- padding: 10px 0;
772
- border-top: 1px solid rgba(255, 255, 255, 0.1);
764
+ font-size: 1.2em;
765
+ color: rgba(255, 255, 255, 0.6);
766
+ padding: 20px 0 50vh 0;
773
767
  margin-top: 10px;
774
- font-weight: normal;
768
+ font-weight: 400;
769
+ opacity: 0.8;
770
+ transition:
771
+ opacity 0.3s ease,
772
+ transform 0.5s cubic-bezier(0.41, 0, 0.12, 0.99),
773
+ filter 0.3s ease;
774
+ transform-origin: left;
775
+ }
776
+
777
+ .lyrics-footer.lyrics-line {
778
+ font-size: 1.2em;
779
+ padding: 20px var(--lyplus-padding-line) 50vh var(--lyplus-padding-line);
780
+ cursor: default;
781
+ }
782
+
783
+ .lyrics-footer.active {
784
+ opacity: 1;
785
+ color: rgba(255, 255, 255, 0.5); /* Grey instead of primary */
786
+ }
787
+
788
+ .lyrics-footer.scroll-animate {
789
+ transition: none !important;
790
+ animation-name: lyrics-scroll;
791
+ animation-duration: var(--scroll-duration, 280ms);
792
+ animation-timing-function: cubic-bezier(0.41, 0, 0.12, 0.99);
793
+ animation-fill-mode: both;
794
+ animation-delay: var(--lyrics-line-delay, 0ms);
795
+ }
796
+
797
+ .lyrics-container.blur-inactive-enabled:not(.not-focused)
798
+ .lyrics-footer:not(.active) {
799
+ filter: blur(var(--lyplus-blur-amount));
800
+ opacity: 0.5;
801
+ }
802
+
803
+ .lyrics-container.user-scrolling .lyrics-footer {
804
+ transition: none !important;
805
+ filter: none !important;
806
+ opacity: 0.8 !important;
775
807
  }
776
808
 
777
809
  .lyrics-footer p {
@@ -779,12 +811,14 @@ export class AmLyrics extends LitElement {
779
811
  }
780
812
 
781
813
  .lyrics-footer a {
782
- color: rgba(255, 255, 255, 0.7);
783
- text-decoration: none;
814
+ color: var(--lyplus-text-primary); /* Stand out using primary color */
815
+ text-underline-offset: 2px;
816
+ opacity: 0.8;
817
+ transition: opacity 0.2s;
784
818
  }
785
819
 
786
820
  .lyrics-footer a:hover {
787
- text-decoration: underline;
821
+ opacity: 1;
788
822
  }
789
823
 
790
824
  .footer-content {
@@ -908,6 +942,7 @@ export class AmLyrics extends LitElement {
908
942
 
909
943
  .lyrics-romanization-container.rtl-text {
910
944
  direction: rtl !important;
945
+ text-align: right;
911
946
  }
912
947
 
913
948
  .lyrics-romanization-container .lyrics-syllable {
@@ -1121,23 +1156,22 @@ export class AmLyrics extends LitElement {
1121
1156
  /* Gap dot animations */
1122
1157
  @keyframes gap-loop {
1123
1158
  from {
1124
- transform: scale(1.12);
1159
+ transform: translateY(-25%) scale(1.12);
1125
1160
  }
1126
1161
  to {
1127
- transform: scale(var(--gap-exit-scale, 0.85));
1162
+ transform: translateY(-25%) scale(var(--gap-exit-scale, 0.85));
1128
1163
  }
1129
1164
  }
1130
1165
 
1131
1166
  @keyframes gap-ended {
1132
1167
  0% {
1133
- transform: translateY(-25%) scale(var(--gap-exit-scale, 0.85))
1134
- translateZ(0);
1168
+ transform: translateY(-25%) scale(var(--gap-exit-scale, 0.85));
1135
1169
  }
1136
1170
  35% {
1137
- transform: translateY(-5%) scale(1.08) translateZ(0);
1171
+ transform: translateY(-25%) scale(1.2);
1138
1172
  }
1139
1173
  100% {
1140
- transform: translateY(-25%) scale(0) translateZ(0);
1174
+ transform: translateY(-25%) scale(0);
1141
1175
  }
1142
1176
  }
1143
1177
 
@@ -1154,17 +1188,18 @@ export class AmLyrics extends LitElement {
1154
1188
  reflow in between) to reliably restart the animation each time */
1155
1189
  @keyframes lyrics-scroll {
1156
1190
  from {
1157
- transform: translateY(var(--scroll-delta)) translateZ(1px);
1191
+ transform: translate3d(0, var(--scroll-delta), 0);
1158
1192
  }
1159
1193
  to {
1160
- transform: translateY(0) translateZ(1px);
1194
+ transform: translate3d(0, 0, 0);
1161
1195
  }
1162
1196
  }
1163
1197
 
1164
- /* Character grow animation - exact copy from YouLyPlus */
1198
+ /* Character grow animation translate3d+scale3d for smooth transform,
1199
+ drop-shadow for glow (text-shadow doesn't work with background-clip:text) */
1165
1200
  @keyframes grow-dynamic {
1166
1201
  0% {
1167
- transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
1202
+ transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
1168
1203
  filter: drop-shadow(
1169
1204
  0 0 0
1170
1205
  color-mix(in srgb, var(--lyplus-lyrics-palette), transparent 100%)
@@ -1172,27 +1207,12 @@ export class AmLyrics extends LitElement {
1172
1207
  }
1173
1208
  25%,
1174
1209
  30% {
1175
- transform: matrix3d(
1176
- calc(var(--max-scale) * calc(var(--lyplus-font-size-base-grow) / 25)),
1177
- 0,
1178
- 0,
1179
- 0,
1180
- 0,
1181
- calc(var(--max-scale) * calc(var(--lyplus-font-size-base-grow) / 25)),
1182
- 0,
1183
- 0,
1184
- 0,
1185
- 0,
1186
- 1,
1187
- 0,
1188
- calc(
1189
- var(--char-offset-x, 0) *
1190
- calc(var(--lyplus-font-size-base-grow) / 25)
1191
- ),
1192
- var(--translate-y-peak, -2),
1193
- 0,
1194
- 1
1195
- );
1210
+ transform: translate3d(
1211
+ var(--char-offset-x, 0px),
1212
+ var(--translate-y-peak, -2px),
1213
+ 0
1214
+ )
1215
+ scale3d(var(--matrix-scale, 1.1), var(--matrix-scale, 1.1), 1);
1196
1216
  filter: drop-shadow(
1197
1217
  0 0 0.1em
1198
1218
  color-mix(
@@ -1202,8 +1222,10 @@ export class AmLyrics extends LitElement {
1202
1222
  )
1203
1223
  );
1204
1224
  }
1225
+ 75%,
1205
1226
  100% {
1206
- transform: translateY(-3.5%) translateZ(1px);
1227
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0)
1228
+ scale3d(1, 1, 1);
1207
1229
  filter: drop-shadow(
1208
1230
  0 0 0
1209
1231
  color-mix(in srgb, var(--lyplus-lyrics-palette), transparent 100%)
@@ -1336,17 +1358,17 @@ export class AmLyrics extends LitElement {
1336
1358
  @property({ type: String, attribute: 'song-album' })
1337
1359
  songAlbum?: string;
1338
1360
 
1361
+ @property({ type: String, attribute: 'songwriters' })
1362
+ songwriters?: string;
1363
+
1339
1364
  @property({ type: Number, attribute: 'song-duration' })
1340
1365
  songDurationMs?: number;
1341
1366
 
1342
1367
  @property({ type: String, attribute: 'highlight-color' })
1343
1368
  highlightColor = '#ffffff';
1344
1369
 
1345
- @property({ type: String, attribute: 'hover-background-color' })
1346
- hoverBackgroundColor = 'rgba(255, 255, 255, 0.13)';
1347
-
1348
1370
  @property({ type: String, attribute: 'font-family' })
1349
- fontFamily?: string;
1371
+ fontFamily: string | undefined;
1350
1372
 
1351
1373
  @property({ type: Boolean })
1352
1374
  autoScroll = true;
@@ -1440,6 +1462,42 @@ export class AmLyrics extends LitElement {
1440
1462
  @property({ type: Number, attribute: 'currenttime', hasChanged: () => false })
1441
1463
  set currentTime(value: number) {
1442
1464
  const oldValue = this._currentTime;
1465
+
1466
+ // If the new time is significantly smaller than the old time (e.g. song looped)
1467
+ if (value < oldValue && oldValue - value > 1000 && this.lyrics) {
1468
+ this.activeLineIndices = [];
1469
+ this.activeMainWordIndices.clear();
1470
+ this.activeBackgroundWordIndices.clear();
1471
+ this.mainWordProgress.clear();
1472
+ this.backgroundWordProgress.clear();
1473
+ this.mainWordAnimations.clear();
1474
+ this.backgroundWordAnimations.clear();
1475
+ this.preActiveLineElements = [];
1476
+ this.positionedLineElements = [];
1477
+ this.activeGapLineElements = [];
1478
+
1479
+ // Stop all running animations and clear highlights immediately
1480
+ if (this.lyricsContainer) {
1481
+ const activeLines = this.lyricsContainer.querySelectorAll(
1482
+ '.lyrics-line.active, .lyrics-line.pre-active',
1483
+ );
1484
+ activeLines.forEach(line => {
1485
+ line.classList.remove('active', 'pre-active');
1486
+ AmLyrics.resetSyllables(line as HTMLElement);
1487
+ });
1488
+
1489
+ const activeGaps = this.lyricsContainer.querySelectorAll(
1490
+ '.lyrics-gap.active, .lyrics-gap.gap-exiting',
1491
+ );
1492
+ activeGaps.forEach(gap =>
1493
+ gap.classList.remove('active', 'gap-exiting'),
1494
+ );
1495
+
1496
+ // Reset gap cache since we manually messed with the elements
1497
+ this.gapElementCache.clear();
1498
+ }
1499
+ }
1500
+
1443
1501
  this._currentTime = value;
1444
1502
  if (oldValue !== value && this.lyrics) {
1445
1503
  this._onTimeChanged(oldValue, value);
@@ -1470,7 +1528,7 @@ export class AmLyrics extends LitElement {
1470
1528
  private lyricsSource: string | null = null;
1471
1529
 
1472
1530
  @state()
1473
- private availableSources: { lines: LyricsLine[]; source: string }[] = [];
1531
+ private availableSources: YouLyPlusLyricsResult[] = [];
1474
1532
 
1475
1533
  @state()
1476
1534
  private currentSourceIndex = 0;
@@ -1556,6 +1614,7 @@ export class AmLyrics extends LitElement {
1556
1614
  vwCharOffset: number[];
1557
1615
  vwStartMs: number[];
1558
1616
  vwEndMs: number[];
1617
+ lineIsRTL: boolean;
1559
1618
  }> | null = null;
1560
1619
 
1561
1620
  // Active line tracking
@@ -1587,6 +1646,13 @@ export class AmLyrics extends LitElement {
1587
1646
 
1588
1647
  private visibleLineIds: Set<string> = new Set();
1589
1648
 
1649
+ // Cached element tracking to avoid repeated querySelectorAll calls
1650
+ private preActiveLineElements: HTMLElement[] = [];
1651
+
1652
+ private positionedLineElements: HTMLElement[] = [];
1653
+
1654
+ private activeGapLineElements: HTMLElement[] = [];
1655
+
1590
1656
  // Bound handler references for proper event listener removal
1591
1657
  private _boundHandleUserScroll = this.handleUserScroll.bind(this);
1592
1658
 
@@ -1633,6 +1699,9 @@ export class AmLyrics extends LitElement {
1633
1699
  this._boundHandleUserScroll,
1634
1700
  );
1635
1701
  }
1702
+ this.preActiveLineElements = [];
1703
+ this.positionedLineElements = [];
1704
+ this.activeGapLineElements = [];
1636
1705
  }
1637
1706
 
1638
1707
  private async fetchLyrics() {
@@ -1661,7 +1730,7 @@ export class AmLyrics extends LitElement {
1661
1730
  !this.query &&
1662
1731
  !this.isrc;
1663
1732
 
1664
- const collectedSources: { lines: LyricsLine[]; source: string }[] = [];
1733
+ const collectedSources: YouLyPlusLyricsResult[] = [];
1665
1734
 
1666
1735
  if (resolvedMetadata?.metadata && !isMusicIdOnlyRequest) {
1667
1736
  const title = resolvedMetadata.metadata.title?.trim() || '';
@@ -1680,20 +1749,7 @@ export class AmLyrics extends LitElement {
1680
1749
  }
1681
1750
 
1682
1751
  if (collectedSources.length === 0 && resolvedMetadata?.metadata) {
1683
- const tidalResult = await AmLyrics.fetchLyricsFromTidal(
1684
- resolvedMetadata.metadata,
1685
- resolvedMetadata.catalogIsrc,
1686
- );
1687
- if (tidalResult && tidalResult.lines.length > 0) {
1688
- collectedSources.push({
1689
- lines: tidalResult.lines,
1690
- source: 'Tidal',
1691
- });
1692
- }
1693
- }
1694
-
1695
- // Fallback: LRCLIB
1696
- if (collectedSources.length === 0 && resolvedMetadata?.metadata) {
1752
+ // Fallback: LRCLIB
1697
1753
  const lrclibResult = await AmLyrics.fetchLyricsFromLrclib(
1698
1754
  resolvedMetadata.metadata,
1699
1755
  );
@@ -1720,10 +1776,7 @@ export class AmLyrics extends LitElement {
1720
1776
  this.hasFetchedAllProviders =
1721
1777
  collectedSources.length === 0 ||
1722
1778
  collectedSources.some(
1723
- s =>
1724
- s.source === 'LRCLIB' ||
1725
- s.source === 'Tidal' ||
1726
- s.source === 'Genius',
1779
+ s => s.source === 'LRCLIB' || s.source === 'Genius',
1727
1780
  );
1728
1781
  this._updateFooter();
1729
1782
 
@@ -1731,8 +1784,12 @@ export class AmLyrics extends LitElement {
1731
1784
  this.availableSources = AmLyrics.mergeAndSortSources(collectedSources);
1732
1785
 
1733
1786
  this.currentSourceIndex = 0;
1734
- this.lyrics = this.availableSources[0].lines;
1735
- this.lyricsSource = this.availableSources[0].source;
1787
+ const sourceResult = this.availableSources[0];
1788
+ this.lyrics = sourceResult.lines;
1789
+ this.lyricsSource = sourceResult.source;
1790
+ if (sourceResult.songwriters) {
1791
+ this.songwriters = sourceResult.songwriters;
1792
+ }
1736
1793
  await this.onLyricsLoaded();
1737
1794
  return;
1738
1795
  }
@@ -1755,6 +1812,9 @@ export class AmLyrics extends LitElement {
1755
1812
  this.backgroundWordProgress.clear();
1756
1813
  this.mainWordAnimations.clear();
1757
1814
  this.backgroundWordAnimations.clear();
1815
+ this.preActiveLineElements = [];
1816
+ this.positionedLineElements = [];
1817
+ this.activeGapLineElements = [];
1758
1818
 
1759
1819
  if (this.lyricsContainer) {
1760
1820
  this.isProgrammaticScroll = true;
@@ -1795,23 +1855,20 @@ export class AmLyrics extends LitElement {
1795
1855
  if (lower.includes('apple') && hasWordSync) return 1;
1796
1856
  if (isQQ && hasWordSync) return 2;
1797
1857
  if (lower.includes('musixmatch') && hasWordSync) return 3;
1798
- if (lower.includes('tidal') && hasWordSync) return 4;
1799
- if (lower.includes('lrclib') && hasWordSync) return 5;
1800
- if (hasWordSync) return 6;
1801
-
1802
- if (lower.includes('apple') && !hasWordSync && !isUnsynced) return 7;
1803
- if (isQQ && !hasWordSync && !isUnsynced) return 8;
1804
- if (lower.includes('musixmatch') && !hasWordSync && !isUnsynced) return 9;
1805
- if (lower.includes('tidal') && !hasWordSync && !isUnsynced) return 10;
1806
- if (lower.includes('lrclib') && !hasWordSync && !isUnsynced) return 11;
1807
- if (!hasWordSync && !isUnsynced) return 12;
1808
-
1809
- if (lower.includes('apple') && isUnsynced) return 13;
1810
- if (isQQ && isUnsynced) return 14;
1811
- if (lower.includes('musixmatch') && isUnsynced) return 15;
1812
- if (lower.includes('tidal') && isUnsynced) return 16;
1813
- if (lower.includes('lrclib') && isUnsynced) return 17;
1814
- if (lower.includes('genius')) return 18;
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;
1815
1872
 
1816
1873
  return 20;
1817
1874
  }
@@ -1855,22 +1912,7 @@ export class AmLyrics extends LitElement {
1855
1912
  try {
1856
1913
  const resolvedMetadata = await this.resolveSongMetadata();
1857
1914
  if (resolvedMetadata?.metadata) {
1858
- const newSources: { lines: LyricsLine[]; source: string }[] = [];
1859
-
1860
- // Try Tidal if not fetched
1861
- if (
1862
- !this.availableSources.some(s =>
1863
- s.source.toLowerCase().includes('tidal'),
1864
- )
1865
- ) {
1866
- const tidalResult = await AmLyrics.fetchLyricsFromTidal(
1867
- resolvedMetadata.metadata,
1868
- resolvedMetadata.catalogIsrc,
1869
- );
1870
- if (tidalResult && tidalResult.lines.length > 0) {
1871
- newSources.push({ lines: tidalResult.lines, source: 'Tidal' });
1872
- }
1873
- }
1915
+ const newSources: YouLyPlusLyricsResult[] = [];
1874
1916
 
1875
1917
  // Try LRCLIB if not fetched
1876
1918
  if (
@@ -1921,8 +1963,12 @@ export class AmLyrics extends LitElement {
1921
1963
  if (this.availableSources.length > 1) {
1922
1964
  this.currentSourceIndex =
1923
1965
  (this.currentSourceIndex + 1) % this.availableSources.length;
1924
- this.lyrics = this.availableSources[this.currentSourceIndex].lines;
1925
- this.lyricsSource = this.availableSources[this.currentSourceIndex].source;
1966
+ const sourceResult = this.availableSources[this.currentSourceIndex];
1967
+ this.lyrics = sourceResult.lines;
1968
+ this.lyricsSource = sourceResult.source;
1969
+ if (sourceResult.songwriters) {
1970
+ this.songwriters = sourceResult.songwriters;
1971
+ }
1926
1972
  await this.onLyricsLoaded();
1927
1973
  }
1928
1974
  }
@@ -1932,6 +1978,7 @@ export class AmLyrics extends LitElement {
1932
1978
  title: this.songTitle?.trim() ?? '',
1933
1979
  artist: this.songArtist?.trim() ?? '',
1934
1980
  album: this.songAlbum?.trim() || undefined,
1981
+ songwriters: this.songwriters?.trim() || undefined,
1935
1982
  durationMs: undefined,
1936
1983
  };
1937
1984
 
@@ -1978,6 +2025,9 @@ export class AmLyrics extends LitElement {
1978
2025
  if (!metadata.album && catalogResult.album) {
1979
2026
  metadata.album = catalogResult.album;
1980
2027
  }
2028
+ if (!metadata.songwriters && catalogResult.songwriters) {
2029
+ metadata.songwriters = catalogResult.songwriters;
2030
+ }
1981
2031
  if (
1982
2032
  metadata.durationMs == null &&
1983
2033
  typeof catalogResult.durationMs === 'number' &&
@@ -2214,9 +2264,13 @@ export class AmLyrics extends LitElement {
2214
2264
  const ttmlRes = await fetchWithTimeout(result.lyricsUrl);
2215
2265
  if (ttmlRes.ok) {
2216
2266
  const ttmlText = await ttmlRes.text();
2217
- const lines = AmLyrics.parseTTML(ttmlText);
2218
- if (lines && lines.length > 0) {
2219
- allResults.push({ lines, source: 'BiniLyrics' });
2267
+ const parseResult = AmLyrics.parseTTML(ttmlText);
2268
+ if (parseResult && parseResult.lines.length > 0) {
2269
+ allResults.push({
2270
+ lines: parseResult.lines,
2271
+ source: 'BiniLyrics',
2272
+ songwriters: parseResult.songwriters,
2273
+ });
2220
2274
  return allResults;
2221
2275
  }
2222
2276
  }
@@ -2251,11 +2305,12 @@ export class AmLyrics extends LitElement {
2251
2305
  const ttmlRes = await fetchWithTimeout(result.lyricsUrl);
2252
2306
  if (ttmlRes.ok) {
2253
2307
  const ttmlText = await ttmlRes.text();
2254
- const lines = AmLyrics.parseTTML(ttmlText);
2255
- if (lines && lines.length > 0) {
2308
+ const parseResult = AmLyrics.parseTTML(ttmlText);
2309
+ if (parseResult && parseResult.lines.length > 0) {
2256
2310
  allResults.push({
2257
- lines,
2311
+ lines: parseResult.lines,
2258
2312
  source: 'BiniLyrics',
2313
+ songwriters: parseResult.songwriters,
2259
2314
  });
2260
2315
  return allResults;
2261
2316
  }
@@ -2412,100 +2467,6 @@ export class AmLyrics extends LitElement {
2412
2467
  return lines;
2413
2468
  }
2414
2469
 
2415
- /**
2416
- * Fetch lyrics from Tidal API.
2417
- * Picks 2 random servers, tries search + lyrics on each.
2418
- */
2419
- private static async fetchLyricsFromTidal(
2420
- metadata: SongMetadata,
2421
- isrc?: string,
2422
- ): Promise<YouLyPlusLyricsResult | null> {
2423
- const title = metadata.title?.trim();
2424
- const artist = metadata.artist?.trim();
2425
-
2426
- if (!title || !artist) return null;
2427
-
2428
- // Pick 3 random unique servers for better reliability
2429
- const shuffled = [...TIDAL_SERVERS].sort(() => Math.random() - 0.5);
2430
- const serversToTry = shuffled.slice(0, 3);
2431
-
2432
- for (const base of serversToTry) {
2433
- try {
2434
- const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
2435
-
2436
- // Step 1: Search for the track
2437
- const searchQuery = `${title} ${artist}`;
2438
- const searchParams = new URLSearchParams({ s: searchQuery });
2439
- // eslint-disable-next-line no-await-in-loop
2440
- const searchResponse = await fetchWithTimeout(
2441
- `${normalizedBase}/search/?${searchParams.toString()}`,
2442
- );
2443
-
2444
- if (!searchResponse.ok) {
2445
- // eslint-disable-next-line no-continue
2446
- continue;
2447
- }
2448
-
2449
- // eslint-disable-next-line no-await-in-loop
2450
- const searchData = await searchResponse.json();
2451
- const items = searchData?.data?.items;
2452
-
2453
- if (!Array.isArray(items) || items.length === 0) {
2454
- // eslint-disable-next-line no-continue
2455
- continue;
2456
- }
2457
-
2458
- // Find best match: prefer ISRC match, then first result
2459
- let bestTrack = items[0];
2460
- if (isrc) {
2461
- const isrcMatch = items.find(
2462
- (item: any) =>
2463
- item.isrc && item.isrc.toLowerCase() === isrc.toLowerCase(),
2464
- );
2465
- if (isrcMatch) {
2466
- bestTrack = isrcMatch;
2467
- }
2468
- }
2469
-
2470
- const trackId = bestTrack?.id;
2471
- if (!trackId) {
2472
- // eslint-disable-next-line no-continue
2473
- continue;
2474
- }
2475
-
2476
- // Step 2: Fetch lyrics
2477
- // eslint-disable-next-line no-await-in-loop
2478
- const lyricsResponse = await fetchWithTimeout(
2479
- `${normalizedBase}/lyrics/?id=${trackId}`,
2480
- );
2481
-
2482
- if (!lyricsResponse.ok) {
2483
- // eslint-disable-next-line no-continue
2484
- continue;
2485
- }
2486
-
2487
- // eslint-disable-next-line no-await-in-loop
2488
- const lyricsData = await lyricsResponse.json();
2489
- const subtitles = lyricsData?.lyrics?.subtitles;
2490
-
2491
- if (subtitles && typeof subtitles === 'string') {
2492
- const lines = AmLyrics.parseLrcSubtitles(subtitles);
2493
- if (lines.length > 0) {
2494
- const provider = lyricsData?.lyrics?.lyricsProvider || 'Tidal';
2495
- return {
2496
- lines,
2497
- source: `Tidal (${provider})`,
2498
- };
2499
- }
2500
- }
2501
- } catch {
2502
- // Try next server
2503
- }
2504
- }
2505
-
2506
- return null;
2507
- }
2508
-
2509
2470
  /**
2510
2471
  * Fetch lyrics from LRCLIB.
2511
2472
  * Uses search endpoint, prefers synced lyrics.
@@ -2701,7 +2662,9 @@ export class AmLyrics extends LitElement {
2701
2662
  return lineSideAssignments;
2702
2663
  }
2703
2664
 
2704
- private static parseTTML(ttmlString: string): LyricsLine[] | null {
2665
+ private static parseTTML(
2666
+ ttmlString: string,
2667
+ ): { lines: LyricsLine[]; songwriters?: string } | null {
2705
2668
  try {
2706
2669
  const parser = new DOMParser();
2707
2670
  const doc = parser.parseFromString(ttmlString, 'text/xml');
@@ -2720,6 +2683,20 @@ export class AmLyrics extends LitElement {
2720
2683
  }
2721
2684
  }
2722
2685
 
2686
+ let songwriters: string | undefined;
2687
+ const songwritersNodes = doc.getElementsByTagName('songwriter');
2688
+ if (songwritersNodes.length > 0) {
2689
+ const names: string[] = [];
2690
+ for (let i = 0; i < songwritersNodes.length; i += 1) {
2691
+ if (songwritersNodes[i].textContent) {
2692
+ names.push(songwritersNodes[i].textContent!);
2693
+ }
2694
+ }
2695
+ if (names.length > 0) {
2696
+ songwriters = names.join(', ');
2697
+ }
2698
+ }
2699
+
2723
2700
  const translationNodes = doc.getElementsByTagName('translation');
2724
2701
  for (let i = 0; i < translationNodes.length; i += 1) {
2725
2702
  const texts = translationNodes[i].getElementsByTagName('text');
@@ -2853,7 +2830,7 @@ export class AmLyrics extends LitElement {
2853
2830
  text: bgText,
2854
2831
  timestamp: timeToMs(bgSpan.getAttribute('begin')),
2855
2832
  endtime: timeToMs(bgSpan.getAttribute('end')),
2856
- part: false,
2833
+ part: !/\s$/.test(bgText),
2857
2834
  });
2858
2835
  }
2859
2836
  // eslint-disable-next-line no-continue
@@ -2882,7 +2859,7 @@ export class AmLyrics extends LitElement {
2882
2859
  text,
2883
2860
  timestamp: timeToMs(span.getAttribute('begin')),
2884
2861
  endtime: timeToMs(span.getAttribute('end')),
2885
- part: false,
2862
+ part: !/\s$/.test(text),
2886
2863
  });
2887
2864
  }
2888
2865
  } else {
@@ -2980,7 +2957,7 @@ export class AmLyrics extends LitElement {
2980
2957
  });
2981
2958
  }
2982
2959
 
2983
- return lines;
2960
+ return { lines, songwriters };
2984
2961
  } catch (e) {
2985
2962
  // eslint-disable-next-line no-console
2986
2963
  console.error('Failed to parse TTML', e);
@@ -3191,7 +3168,9 @@ export class AmLyrics extends LitElement {
3191
3168
  if (!newActiveLines.includes(lineIndex)) {
3192
3169
  const lineElement = this._getLineElement(lineIndex);
3193
3170
  if (lineElement) {
3194
- lineElement.classList.remove('active');
3171
+ lineElement.classList.remove('active', 'pre-active');
3172
+ const preIdx = this.preActiveLineElements.indexOf(lineElement);
3173
+ if (preIdx !== -1) this.preActiveLineElements.splice(preIdx, 1);
3195
3174
  AmLyrics.resetSyllables(lineElement);
3196
3175
  }
3197
3176
  }
@@ -3203,6 +3182,8 @@ export class AmLyrics extends LitElement {
3203
3182
  if (lineElement) {
3204
3183
  lineElement.classList.add('active');
3205
3184
  lineElement.classList.remove('pre-active');
3185
+ const preIdx = this.preActiveLineElements.indexOf(lineElement);
3186
+ if (preIdx !== -1) this.preActiveLineElements.splice(preIdx, 1);
3206
3187
  }
3207
3188
  }
3208
3189
  }
@@ -3227,11 +3208,9 @@ export class AmLyrics extends LitElement {
3227
3208
  }
3228
3209
 
3229
3210
  // Also update syllables in active gap lines (breathing dots)
3230
- const activeGaps =
3231
- this.lyricsContainer.querySelectorAll('.lyrics-gap.active');
3232
- activeGaps.forEach(gapLine => {
3233
- AmLyrics.updateSyllablesForLine(gapLine as HTMLElement, newTime);
3234
- });
3211
+ for (const gapLine of this.activeGapLineElements) {
3212
+ AmLyrics.updateSyllablesForLine(gapLine, newTime);
3213
+ }
3235
3214
 
3236
3215
  // Imperatively manage gap active state
3237
3216
  if (this.gapElementCache.size > 0) {
@@ -3250,9 +3229,24 @@ export class AmLyrics extends LitElement {
3250
3229
  const shouldStartExiting =
3251
3230
  isActive && !isExiting && newTime >= gapEndTime - exitLeadMs;
3252
3231
 
3253
- if (shouldBeActive && !isActive && !isExiting) {
3232
+ if (shouldBeActive && (!isActive || isSeek) && !isExiting) {
3254
3233
  gap.classList.remove('gap-exiting');
3234
+ if (isSeek && isActive) {
3235
+ gap.classList.remove('active');
3236
+ // eslint-disable-next-line no-void
3237
+ void (gap as HTMLElement).offsetWidth; // Force reflow
3238
+ }
3239
+ const gapDuration = gapEndTime - gapStartTime;
3240
+ const baseLoopDelay = AmLyrics.getGapLoopDelay(gapDuration);
3241
+ const totalDelay = baseLoopDelay + (newTime - gapStartTime);
3242
+ (gap as HTMLElement).style.setProperty(
3243
+ '--gap-loop-delay',
3244
+ `-${totalDelay}ms`,
3245
+ );
3255
3246
  gap.classList.add('active');
3247
+ if (!this.activeGapLineElements.includes(gap as HTMLElement)) {
3248
+ this.activeGapLineElements.push(gap as HTMLElement);
3249
+ }
3256
3250
  const dotSyllables = gap.querySelectorAll('.lyrics-syllable');
3257
3251
  dotSyllables.forEach(dot => {
3258
3252
  const dotStart = parseFloat(
@@ -3264,21 +3258,39 @@ export class AmLyrics extends LitElement {
3264
3258
  if (newTime > dotEnd) {
3265
3259
  dot.classList.add('finished');
3266
3260
  if (!dot.classList.contains('highlight')) {
3267
- AmLyrics.updateSyllableAnimation(dot as HTMLElement);
3261
+ AmLyrics.updateSyllableAnimation(
3262
+ dot as HTMLElement,
3263
+ newTime - dotStart,
3264
+ );
3268
3265
  }
3269
3266
  } else if (newTime >= dotStart && newTime <= dotEnd) {
3270
- AmLyrics.updateSyllableAnimation(dot as HTMLElement);
3267
+ AmLyrics.updateSyllableAnimation(
3268
+ dot as HTMLElement,
3269
+ newTime - dotStart,
3270
+ );
3271
3271
  }
3272
3272
  });
3273
3273
  } else if (shouldStartExiting) {
3274
- gap.classList.add('gap-exiting');
3274
+ // Cancel gap-loop first, force reflow, then start gap-ended
3275
+ // so the browser sees a clean animation swap
3275
3276
  gap.classList.remove('active');
3277
+ // eslint-disable-next-line no-void
3278
+ void (gap as HTMLElement).offsetWidth;
3279
+ gap.classList.add('gap-exiting');
3280
+ const gapIdx = this.activeGapLineElements.indexOf(
3281
+ gap as HTMLElement,
3282
+ );
3283
+ if (gapIdx !== -1) this.activeGapLineElements.splice(gapIdx, 1);
3276
3284
  setTimeout(() => {
3277
3285
  gap.classList.remove('gap-exiting');
3278
3286
  }, GAP_EXIT_LEAD_MS);
3279
- } else if (isActive && !shouldBeActive) {
3287
+ } else if (!shouldBeActive && (isActive || isExiting)) {
3280
3288
  gap.classList.remove('active');
3281
3289
  gap.classList.remove('gap-exiting');
3290
+ const gapIdx = this.activeGapLineElements.indexOf(
3291
+ gap as HTMLElement,
3292
+ );
3293
+ if (gapIdx !== -1) this.activeGapLineElements.splice(gapIdx, 1);
3282
3294
  } else if (isExiting && newTime < gapEndTime - exitLeadMs) {
3283
3295
  gap.classList.remove('gap-exiting');
3284
3296
  }
@@ -3301,18 +3313,44 @@ export class AmLyrics extends LitElement {
3301
3313
  const shouldStartExiting =
3302
3314
  isActive && !isExiting && newTime >= gapEndTime - exitLeadMs;
3303
3315
 
3304
- if (shouldBeActive && !isActive && !isExiting) {
3316
+ if (shouldBeActive && (!isActive || isSeek) && !isExiting) {
3305
3317
  gap.classList.remove('gap-exiting');
3318
+ if (isSeek && isActive) {
3319
+ gap.classList.remove('active');
3320
+ // eslint-disable-next-line no-void
3321
+ void (gap as HTMLElement).offsetWidth; // Force reflow
3322
+ }
3323
+ const gapDuration = gapEndTime - gapStartTime;
3324
+ const baseLoopDelay = AmLyrics.getGapLoopDelay(gapDuration);
3325
+ const totalDelay = baseLoopDelay + (newTime - gapStartTime);
3326
+ (gap as HTMLElement).style.setProperty(
3327
+ '--gap-loop-delay',
3328
+ `-${totalDelay}ms`,
3329
+ );
3306
3330
  gap.classList.add('active');
3331
+ if (!this.activeGapLineElements.includes(gap as HTMLElement)) {
3332
+ this.activeGapLineElements.push(gap as HTMLElement);
3333
+ }
3307
3334
  } else if (shouldStartExiting) {
3308
- gap.classList.add('gap-exiting');
3335
+ // Cancel gap-loop first, force reflow, then start gap-ended
3309
3336
  gap.classList.remove('active');
3337
+ // eslint-disable-next-line no-void
3338
+ void (gap as HTMLElement).offsetWidth;
3339
+ gap.classList.add('gap-exiting');
3340
+ const gapIdx = this.activeGapLineElements.indexOf(
3341
+ gap as HTMLElement,
3342
+ );
3343
+ if (gapIdx !== -1) this.activeGapLineElements.splice(gapIdx, 1);
3310
3344
  setTimeout(() => {
3311
3345
  gap.classList.remove('gap-exiting');
3312
3346
  }, GAP_EXIT_LEAD_MS);
3313
- } else if (isActive && !shouldBeActive) {
3347
+ } else if (!shouldBeActive && (isActive || isExiting)) {
3314
3348
  gap.classList.remove('active');
3315
3349
  gap.classList.remove('gap-exiting');
3350
+ const gapIdx = this.activeGapLineElements.indexOf(
3351
+ gap as HTMLElement,
3352
+ );
3353
+ if (gapIdx !== -1) this.activeGapLineElements.splice(gapIdx, 1);
3316
3354
  } else if (isExiting && newTime < gapEndTime - exitLeadMs) {
3317
3355
  gap.classList.remove('gap-exiting');
3318
3356
  }
@@ -3327,6 +3365,30 @@ export class AmLyrics extends LitElement {
3327
3365
  this.lastInstrumentalIndex = null;
3328
3366
  }
3329
3367
 
3368
+ // Check footer active state
3369
+ const lastLyric =
3370
+ this.lyrics && this.lyrics.length > 0
3371
+ ? this.lyrics[this.lyrics.length - 1]
3372
+ : null;
3373
+ const footer = this.lyricsContainer.querySelector(
3374
+ '.lyrics-footer',
3375
+ ) as HTMLElement;
3376
+ if (footer && lastLyric && lastLyric.endtime > 0) {
3377
+ const isFooterActive = newTime > lastLyric.endtime + 200; // Snappier 200ms buffer
3378
+ if (isFooterActive && !footer.classList.contains('active')) {
3379
+ footer.classList.add('active');
3380
+ if (
3381
+ this.autoScroll &&
3382
+ !this.isUserScrolling &&
3383
+ !this.isClickSeeking
3384
+ ) {
3385
+ this.focusLine(footer);
3386
+ }
3387
+ } else if (!isFooterActive && footer.classList.contains('active')) {
3388
+ footer.classList.remove('active');
3389
+ }
3390
+ }
3391
+
3330
3392
  // Pre-scroll: scroll to upcoming line ~0.5s before it starts
3331
3393
  if (
3332
3394
  this.autoScroll &&
@@ -3357,6 +3419,9 @@ export class AmLyrics extends LitElement {
3357
3419
 
3358
3420
  if (!isBackToBack) {
3359
3421
  nextLineEl.classList.add('pre-active');
3422
+ if (!this.preActiveLineElements.includes(nextLineEl)) {
3423
+ this.preActiveLineElements.push(nextLineEl);
3424
+ }
3360
3425
  }
3361
3426
  this.clearPreActiveClasses(i);
3362
3427
 
@@ -3396,6 +3461,10 @@ export class AmLyrics extends LitElement {
3396
3461
  const lineEl = this._getLineElement(lineIndex);
3397
3462
  if (lineEl) lineEl.classList.add('active');
3398
3463
  }
3464
+
3465
+ // Trigger a faux time-change so that updateSyllablesForLine fires
3466
+ // to setup inline syllable CSS wipe animations for whatever the current time is
3467
+ this._onTimeChanged(0, this.currentTime);
3399
3468
  }
3400
3469
  }
3401
3470
 
@@ -3409,6 +3478,9 @@ export class AmLyrics extends LitElement {
3409
3478
  this.backgroundWordProgress.clear();
3410
3479
  this.mainWordAnimations.clear();
3411
3480
  this.backgroundWordAnimations.clear();
3481
+ this.preActiveLineElements = [];
3482
+ this.positionedLineElements = [];
3483
+ this.activeGapLineElements = [];
3412
3484
  this.setUserScrolling(false);
3413
3485
 
3414
3486
  // Cancel any running animations
@@ -3487,8 +3559,8 @@ export class AmLyrics extends LitElement {
3487
3559
  this.lyrics[prevPrimaryIndex].endtime;
3488
3560
  if (gap > 200) {
3489
3561
  scrollDuration = Math.min(
3490
- Math.max(gap * 0.6, SCROLL_ANIMATION_DURATION_MS),
3491
- 2000,
3562
+ Math.max(gap * 0.85, SCROLL_ANIMATION_DURATION_MS),
3563
+ 4000,
3492
3564
  );
3493
3565
  }
3494
3566
  }
@@ -3563,6 +3635,9 @@ export class AmLyrics extends LitElement {
3563
3635
  this.cachedLineData = null;
3564
3636
  this.lineElementCache.clear();
3565
3637
  this.gapElementCache.clear();
3638
+ this.preActiveLineElements = [];
3639
+ this.positionedLineElements = [];
3640
+ this.activeGapLineElements = [];
3566
3641
  }
3567
3642
 
3568
3643
  private _updateCachedIsUnsynced() {
@@ -3577,12 +3652,28 @@ export class AmLyrics extends LitElement {
3577
3652
 
3578
3653
  this.cachedLineData = this.lyrics.map(line => {
3579
3654
  const wordGroups: Syllable[][] = [];
3580
- for (const syllable of line.text) {
3581
- if (syllable.part && wordGroups.length > 0) {
3582
- wordGroups[wordGroups.length - 1].push(syllable);
3583
- } else {
3584
- wordGroups.push([syllable]);
3655
+ let currentGroupBuffer: Syllable[] = [];
3656
+
3657
+ line.text.forEach((syllable, idx) => {
3658
+ currentGroupBuffer.push(syllable);
3659
+ const nextSyllable = line.text[idx + 1];
3660
+
3661
+ const endsWithDelimiter =
3662
+ !nextSyllable ||
3663
+ syllable.part === false ||
3664
+ /\s$/.test(syllable.text) ||
3665
+ (nextSyllable &&
3666
+ (syllable as any).isBackground !==
3667
+ (nextSyllable as any).isBackground);
3668
+
3669
+ if (endsWithDelimiter) {
3670
+ wordGroups.push(currentGroupBuffer);
3671
+ currentGroupBuffer = [];
3585
3672
  }
3673
+ });
3674
+
3675
+ if (currentGroupBuffer.length > 0) {
3676
+ wordGroups.push(currentGroupBuffer);
3586
3677
  }
3587
3678
 
3588
3679
  const groupGrowable: boolean[] = new Array(wordGroups.length).fill(false);
@@ -3593,6 +3684,7 @@ export class AmLyrics extends LitElement {
3593
3684
  const vwStartMs: number[] = new Array(wordGroups.length).fill(0);
3594
3685
  const vwEndMs: number[] = new Array(wordGroups.length).fill(0);
3595
3686
 
3687
+ let lineIsRTL = false;
3596
3688
  let vwStart = 0;
3597
3689
  while (vwStart < wordGroups.length) {
3598
3690
  let vwEnd = vwStart;
@@ -3621,22 +3713,25 @@ export class AmLyrics extends LitElement {
3621
3713
  /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF]/.test(
3622
3714
  combinedText,
3623
3715
  );
3716
+ if (isRTL) lineIsRTL = true;
3624
3717
  const hasHyphen = combinedText.includes('-');
3625
3718
 
3626
3719
  const wordLen = combinedText.length;
3627
3720
  let isGrowableVW =
3628
- !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 12;
3721
+ !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 7;
3629
3722
  if (isGrowableVW) {
3630
3723
  if (wordLen < 3) {
3631
3724
  isGrowableVW =
3632
- combinedDuration >= 1000 && combinedDuration >= wordLen * 500;
3725
+ combinedDuration >= 1050 && combinedDuration >= wordLen * 525;
3633
3726
  } else {
3634
3727
  isGrowableVW =
3635
- combinedDuration >= 800 && combinedDuration >= wordLen * 180;
3728
+ combinedDuration >= 850 && combinedDuration >= wordLen * 190;
3636
3729
  }
3637
3730
  }
3638
3731
 
3639
- const isGlowingVW = isGrowableVW;
3732
+ const isLineSynced =
3733
+ line.isWordSynced === false || line.text.some(s => s.lineSynced);
3734
+ const isGlowingVW = isGrowableVW && !isLineSynced;
3640
3735
 
3641
3736
  let charOff = 0;
3642
3737
  for (let gi = vwStart; gi <= vwEnd; gi += 1) {
@@ -3663,6 +3758,7 @@ export class AmLyrics extends LitElement {
3663
3758
  vwCharOffset,
3664
3759
  vwStartMs,
3665
3760
  vwEndMs,
3761
+ lineIsRTL,
3666
3762
  };
3667
3763
  });
3668
3764
  }
@@ -3768,15 +3864,16 @@ export class AmLyrics extends LitElement {
3768
3864
  private clearPreActiveClasses(exceptLineIndex: number | null = null): void {
3769
3865
  if (!this.lyricsContainer) return;
3770
3866
 
3771
- this.lyricsContainer
3772
- .querySelectorAll('.lyrics-line.pre-active')
3773
- .forEach(element => {
3774
- const lineElement = element as HTMLElement;
3775
- const lineIndex = AmLyrics.getLineIndexFromElement(lineElement);
3776
- if (lineIndex !== exceptLineIndex) {
3777
- lineElement.classList.remove('pre-active');
3778
- }
3779
- });
3867
+ const keptLines: HTMLElement[] = [];
3868
+ for (const lineElement of this.preActiveLineElements) {
3869
+ const lineIndex = AmLyrics.getLineIndexFromElement(lineElement);
3870
+ if (lineIndex === exceptLineIndex) {
3871
+ keptLines.push(lineElement);
3872
+ } else {
3873
+ lineElement.classList.remove('pre-active');
3874
+ }
3875
+ }
3876
+ this.preActiveLineElements = keptLines;
3780
3877
  }
3781
3878
 
3782
3879
  private getPrimaryActiveLineIndex(activeIndices: number[]): number | null {
@@ -4342,6 +4439,7 @@ export class AmLyrics extends LitElement {
4342
4439
  // Clean up any lingering scroll animations before smooth scroll
4343
4440
  for (const line of animatingLines) {
4344
4441
  line.classList.remove('scroll-animate');
4442
+ line.style.removeProperty('will-change');
4345
4443
  line.style.removeProperty('--scroll-delta');
4346
4444
  line.style.removeProperty('--lyrics-line-delay');
4347
4445
  line.style.removeProperty('--scroll-duration');
@@ -4415,6 +4513,7 @@ export class AmLyrics extends LitElement {
4415
4513
  // --- Step 4: Re-add scroll-animate class to start fresh animations ---
4416
4514
  for (const line of newAnimatingLines) {
4417
4515
  line.classList.add('scroll-animate');
4516
+ line.style.willChange = 'transform';
4418
4517
  animatingLines.push(line);
4419
4518
  }
4420
4519
 
@@ -4435,6 +4534,7 @@ export class AmLyrics extends LitElement {
4435
4534
  for (let i = 0; i < animatingLines.length; i += 1) {
4436
4535
  const line = animatingLines[i];
4437
4536
  line.classList.remove('scroll-animate');
4537
+ line.style.removeProperty('will-change');
4438
4538
  line.style.removeProperty('--scroll-delta');
4439
4539
  line.style.removeProperty('--lyrics-line-delay');
4440
4540
  line.style.removeProperty('--scroll-duration');
@@ -4466,13 +4566,15 @@ export class AmLyrics extends LitElement {
4466
4566
  'next-4',
4467
4567
  ];
4468
4568
 
4469
- // Remove old position classes
4470
- this.lyricsContainer
4471
- .querySelectorAll(`.${positionClasses.join(', .')}`)
4472
- .forEach(el => el.classList.remove(...positionClasses));
4569
+ // Remove old position classes from tracked elements
4570
+ for (const el of this.positionedLineElements) {
4571
+ el.classList.remove(...positionClasses);
4572
+ }
4573
+ this.positionedLineElements = [];
4473
4574
 
4474
4575
  // Add new position classes
4475
4576
  lineToScroll.classList.add('lyrics-activest');
4577
+ this.positionedLineElements.push(lineToScroll);
4476
4578
 
4477
4579
  const lineElements = Array.from(
4478
4580
  this.lyricsContainer.querySelectorAll('.lyrics-line'),
@@ -4492,6 +4594,7 @@ export class AmLyrics extends LitElement {
4492
4594
  else if (position < 0)
4493
4595
  element.classList.add(`prev-${Math.abs(position)}`);
4494
4596
  else element.classList.add(`next-${position}`);
4597
+ this.positionedLineElements.push(element);
4495
4598
  }
4496
4599
  }
4497
4600
  }
@@ -4524,7 +4627,7 @@ export class AmLyrics extends LitElement {
4524
4627
  }
4525
4628
 
4526
4629
  // Skip scroll if near the bottom of content (prevents footer jitter)
4527
- if (!forceScroll) {
4630
+ if (!forceScroll && !activeLine.classList.contains('lyrics-footer')) {
4528
4631
  const parent = this.lyricsContainer;
4529
4632
  const atBottom =
4530
4633
  parent.scrollTop + parent.clientHeight >= parent.scrollHeight - 50;
@@ -4554,7 +4657,10 @@ export class AmLyrics extends LitElement {
4554
4657
  * Update syllable highlight animation - apply CSS wipe animation
4555
4658
  * (Exact copy from YouLyPlus _updateSyllableAnimation)
4556
4659
  */
4557
- private static updateSyllableAnimation(syllable: HTMLElement): void {
4660
+ private static updateSyllableAnimation(
4661
+ syllable: HTMLElement,
4662
+ elapsedTimeMs = 0,
4663
+ ): void {
4558
4664
  if (syllable.classList.contains('highlight')) return;
4559
4665
 
4560
4666
  const { classList } = syllable;
@@ -4597,10 +4703,8 @@ export class AmLyrics extends LitElement {
4597
4703
  const growDurationMs = finalDuration * 1.5;
4598
4704
 
4599
4705
  allWordCharSpans.forEach(span => {
4600
- const horizontalOffset = parseFloat(
4601
- span.dataset.horizontalOffset || '0',
4602
- );
4603
- const maxScale = span.dataset.maxScale || '1.1';
4706
+ const matrixScale = span.dataset.matrixScale || '1.1';
4707
+ const charOffsetX = span.dataset.charOffsetX || '0';
4604
4708
  const shadowIntensity = span.dataset.shadowIntensity || '0.6';
4605
4709
  const translateYPeak = span.dataset.translateYPeak || '-2';
4606
4710
 
@@ -4616,13 +4720,13 @@ export class AmLyrics extends LitElement {
4616
4720
 
4617
4721
  styleUpdates.push({
4618
4722
  element: span,
4619
- property: '--char-offset-x',
4620
- value: `${horizontalOffset}`,
4723
+ property: '--matrix-scale',
4724
+ value: matrixScale,
4621
4725
  });
4622
4726
  styleUpdates.push({
4623
4727
  element: span,
4624
- property: '--max-scale',
4625
- value: maxScale,
4728
+ property: '--char-offset-x',
4729
+ value: `${charOffsetX}px`,
4626
4730
  });
4627
4731
  styleUpdates.push({
4628
4732
  element: span,
@@ -4632,7 +4736,7 @@ export class AmLyrics extends LitElement {
4632
4736
  styleUpdates.push({
4633
4737
  element: span,
4634
4738
  property: '--translate-y-peak',
4635
- value: `${translateYPeak}`,
4739
+ value: `${translateYPeak}px`,
4636
4740
  });
4637
4741
  });
4638
4742
  }
@@ -4643,7 +4747,7 @@ export class AmLyrics extends LitElement {
4643
4747
  const startPct = parseFloat(span.dataset.wipeStart || '0');
4644
4748
  const durationPct = parseFloat(span.dataset.wipeDuration || '0');
4645
4749
 
4646
- const wipeDelay = syllableDurationMs * startPct;
4750
+ const wipeDelay = syllableDurationMs * startPct - elapsedTimeMs;
4647
4751
  const wipeDuration = syllableDurationMs * durationPct;
4648
4752
 
4649
4753
  const useStartAnimation = isFirstInContainer && charIndex === 0;
@@ -4663,9 +4767,10 @@ export class AmLyrics extends LitElement {
4663
4767
  animationParts.push(existingAnimation.split(',')[0].trim());
4664
4768
  }
4665
4769
  if (charIndex > 0) {
4666
- const arrivalTime = span.dataset.preWipeArrival
4667
- ? parseFloat(span.dataset.preWipeArrival)
4668
- : wipeDelay;
4770
+ const arrivalTime =
4771
+ (span.dataset.preWipeArrival
4772
+ ? parseFloat(span.dataset.preWipeArrival)
4773
+ : syllableDurationMs * startPct) - elapsedTimeMs;
4669
4774
  const constantDuration = parseFloat(
4670
4775
  span.dataset.preWipeDuration || '100',
4671
4776
  );
@@ -4706,7 +4811,7 @@ export class AmLyrics extends LitElement {
4706
4811
 
4707
4812
  const currentWipeAnimation = isGap ? 'fade-gap' : wipeAnimation;
4708
4813
  // eslint-disable-next-line no-param-reassign
4709
- syllable.style.animation = `${currentWipeAnimation} ${visualDuration}ms ${isGap ? 'ease-out' : 'linear'} forwards`;
4814
+ syllable.style.animation = `${currentWipeAnimation} ${visualDuration}ms ${isGap ? 'ease-out' : 'linear'} ${-elapsedTimeMs}ms forwards`;
4710
4815
  }
4711
4816
 
4712
4817
  // --- WRITE PHASE ---
@@ -4714,6 +4819,7 @@ export class AmLyrics extends LitElement {
4714
4819
  classList.add('highlight');
4715
4820
 
4716
4821
  for (const [span, animationString] of charAnimationsMap.entries()) {
4822
+ span.style.willChange = 'transform';
4717
4823
  span.style.animation = animationString;
4718
4824
  }
4719
4825
 
@@ -4742,6 +4848,7 @@ export class AmLyrics extends LitElement {
4742
4848
  syllable.querySelectorAll('span.char').forEach(span => {
4743
4849
  const el = span as HTMLElement;
4744
4850
  el.style.animation = '';
4851
+ el.style.willChange = '';
4745
4852
  el.style.transition = 'none';
4746
4853
  el.style.backgroundColor = 'var(--lyplus-text-secondary)';
4747
4854
  });
@@ -4762,6 +4869,7 @@ export class AmLyrics extends LitElement {
4762
4869
  const el = span as HTMLElement;
4763
4870
  el.style.removeProperty('background-color');
4764
4871
  el.style.removeProperty('transition');
4872
+ el.style.removeProperty('will-change');
4765
4873
  });
4766
4874
  });
4767
4875
  }
@@ -4803,7 +4911,7 @@ export class AmLyrics extends LitElement {
4803
4911
  );
4804
4912
  const endTime = parseFloat(syllable.getAttribute('data-end-time') || '0');
4805
4913
 
4806
- if (startTime) {
4914
+ if (Number.isFinite(startTime) && Number.isFinite(endTime)) {
4807
4915
  const { classList } = syllable;
4808
4916
  const hasHighlight = classList.contains('highlight');
4809
4917
  const hasFinished = classList.contains('finished');
@@ -4830,7 +4938,10 @@ export class AmLyrics extends LitElement {
4830
4938
  if (currentTimeMs >= startTime && currentTimeMs <= endTime) {
4831
4939
  // Currently active
4832
4940
  if (!hasHighlight) {
4833
- AmLyrics.updateSyllableAnimation(syllable);
4941
+ AmLyrics.updateSyllableAnimation(
4942
+ syllable,
4943
+ currentTimeMs - startTime,
4944
+ );
4834
4945
  }
4835
4946
  if (hasFinished) {
4836
4947
  classList.remove('finished');
@@ -4839,7 +4950,10 @@ export class AmLyrics extends LitElement {
4839
4950
  // Finished
4840
4951
  if (!hasFinished) {
4841
4952
  if (!hasHighlight) {
4842
- AmLyrics.updateSyllableAnimation(syllable);
4953
+ AmLyrics.updateSyllableAnimation(
4954
+ syllable,
4955
+ currentTimeMs - startTime,
4956
+ );
4843
4957
  }
4844
4958
  classList.add('finished');
4845
4959
  }
@@ -5127,10 +5241,6 @@ export class AmLyrics extends LitElement {
5127
5241
 
5128
5242
  // Set both old internal CSS variables (for backward compatibility)
5129
5243
  // and new public CSS variables (which take precedence)
5130
- this.style.setProperty(
5131
- '--hover-background-color',
5132
- this.hoverBackgroundColor,
5133
- );
5134
5244
  this.style.setProperty('--highlight-color', this.highlightColor);
5135
5245
 
5136
5246
  const sourceLabel = this.lyricsSource ?? 'Unavailable';
@@ -5197,21 +5307,24 @@ export class AmLyrics extends LitElement {
5197
5307
  >`
5198
5308
  : '';
5199
5309
 
5200
- return html`<span class="lyrics-word">
5201
- <span class="lyrics-syllable-wrap">
5202
- <span
5203
- class="lyrics-syllable ${syllable.lineSynced
5204
- ? 'line-synced'
5310
+ return html`<span class="lyrics-word"
5311
+ ><span
5312
+ class="lyrics-syllable-wrap${bgRomanizedText
5313
+ ? ' has-transliteration'
5314
+ : ''}"
5315
+ ><span
5316
+ class="lyrics-syllable no-chars${syllable.lineSynced
5317
+ ? ' line-synced'
5205
5318
  : ''}"
5206
5319
  data-start-time="${startTimeMs}"
5207
5320
  data-end-time="${endTimeMs}"
5208
5321
  data-duration="${durationMs}"
5209
5322
  data-syllable-index="${syllableIndex}"
5323
+ data-wipe-ratio="1"
5210
5324
  >${syllable.text}</span
5211
- >
5212
- ${bgRomanizedText}
5213
- </span>
5214
- </span>`;
5325
+ >${bgRomanizedText}</span
5326
+ ></span
5327
+ >`;
5215
5328
  })}
5216
5329
  </p>`
5217
5330
  : '';
@@ -5232,9 +5345,12 @@ export class AmLyrics extends LitElement {
5232
5345
  const vwFullText = lineData?.vwFullText ?? [];
5233
5346
  const vwFullDuration = lineData?.vwFullDuration ?? [];
5234
5347
  const vwCharOffset = lineData?.vwCharOffset ?? [];
5348
+ const lineIsRTL = lineData?.lineIsRTL ?? false;
5235
5349
 
5236
5350
  // Create main vocals using YouLyPlus syllable structure
5237
- const mainVocalElement = html`<p class="main-vocal-container">
5351
+ const mainVocalElement = html`<p
5352
+ class="main-vocal-container ${lineIsRTL ? 'rtl-text' : ''}"
5353
+ >
5238
5354
  ${wordGroups.map((group, groupIdx) => {
5239
5355
  const isGrowable = groupGrowable[groupIdx];
5240
5356
  const isGlowing = groupGlowing[groupIdx];
@@ -5247,12 +5363,29 @@ export class AmLyrics extends LitElement {
5247
5363
 
5248
5364
  let sylCharAccumulator = 0;
5249
5365
 
5366
+ const groupText = group.map(s => s.text).join('');
5367
+ const shouldAllowBreak =
5368
+ groupText.trim().length >= 16 ||
5369
+ /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(
5370
+ groupText,
5371
+ );
5372
+
5373
+ // Calculate dynamic rise duration based on the audio duration of the word
5374
+ const wordStartTimeMs = group[0].timestamp;
5375
+ const wordEndTimeMs = group[group.length - 1].endtime;
5376
+ const actualDurationMs = wordEndTimeMs - wordStartTimeMs;
5377
+ // Base float is 0.8s, plus a portion of the audio duration, capped between 1.0s and 2.5s
5378
+ const riseDuration = Math.max(
5379
+ 1.2,
5380
+ Math.min(2.5, 1.2 + (actualDurationMs / 1000) * 0.6),
5381
+ );
5382
+
5250
5383
  return html`<span
5251
- class="lyrics-word ${isGrowable ? 'growable' : ''} ${isGlowing
5252
- ? 'glowing'
5253
- : ''} ${group.length > 1 ? 'allow-break' : ''}"
5254
- >
5255
- ${group.map((syllable, sylIdx) => {
5384
+ class="lyrics-word${isGrowable ? ' growable' : ''}${isGlowing
5385
+ ? ' glowing'
5386
+ : ''}${shouldAllowBreak ? ' allow-break' : ''}"
5387
+ style="--rise-duration: ${riseDuration}s"
5388
+ >${group.map((syllable, sylIdx) => {
5256
5389
  const startTimeMs = syllable.timestamp;
5257
5390
  const endTimeMs = syllable.endtime;
5258
5391
  const durationMs = endTimeMs - startTimeMs;
@@ -5367,6 +5500,10 @@ export class AmLyrics extends LitElement {
5367
5500
  )}"
5368
5501
  data-horizontal-offset="${horizontalOffset.toFixed(2)}"
5369
5502
  data-max-scale="${charMaxScale.toFixed(3)}"
5503
+ data-matrix-scale="${(charMaxScale * 0.98).toFixed(3)}"
5504
+ data-char-offset-x="${(horizontalOffset * 0.98).toFixed(
5505
+ 2,
5506
+ )}"
5370
5507
  data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
5371
5508
  data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
5372
5509
  >${char}</span
@@ -5374,11 +5511,14 @@ export class AmLyrics extends LitElement {
5374
5511
  })}`;
5375
5512
  }
5376
5513
 
5377
- return html`<span class="lyrics-syllable-wrap">
5378
- <span
5379
- class="lyrics-syllable ${groupLineSynced
5380
- ? 'line-synced'
5381
- : ''}"
5514
+ return html`<span
5515
+ class="lyrics-syllable-wrap${romanizedText
5516
+ ? ' has-transliteration'
5517
+ : ''}"
5518
+ ><span
5519
+ class="lyrics-syllable${groupLineSynced
5520
+ ? ' line-synced'
5521
+ : ''}${isGrowable ? ' has-chars' : ' no-chars'}"
5382
5522
  data-start-time="${startTimeMs}"
5383
5523
  data-end-time="${endTimeMs}"
5384
5524
  data-duration="${durationMs}"
@@ -5386,11 +5526,10 @@ export class AmLyrics extends LitElement {
5386
5526
  data-syllable-index="${sylIdx}"
5387
5527
  data-wipe-ratio="1"
5388
5528
  >${syllableContent}</span
5389
- >
5390
- ${romanizedText}
5391
- </span>`;
5392
- })}
5393
- </span>`;
5529
+ >${romanizedText}</span
5530
+ >`;
5531
+ })}</span
5532
+ >`;
5394
5533
  })}
5395
5534
  </p>`;
5396
5535
 
@@ -5416,7 +5555,11 @@ export class AmLyrics extends LitElement {
5416
5555
  line.romanizedText &&
5417
5556
  !line.text.some(s => s.romanizedText) &&
5418
5557
  line.romanizedText.trim() !== fullLineText
5419
- ? html`<div class="lyrics-romanization-container">
5558
+ ? html`<div
5559
+ class="lyrics-romanization-container ${lineIsRTL
5560
+ ? 'rtl-text'
5561
+ : ''}"
5562
+ >
5420
5563
  ${line.romanizedText}
5421
5564
  </div>`
5422
5565
  : '';
@@ -5438,42 +5581,37 @@ export class AmLyrics extends LitElement {
5438
5581
  data-end-time="${gapForLine.gapEnd}"
5439
5582
  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};"
5440
5583
  >
5441
- <div class="lyrics-line-container">
5442
- <p class="main-vocal-container">
5443
- <span class="lyrics-word">
5444
- <span class="lyrics-syllable-wrap">
5445
- <span
5446
- class="lyrics-syllable"
5447
- data-start-time="${gapForLine.gapStart}"
5448
- data-end-time="${gapForLine.gapStart + dotDuration}"
5449
- data-duration="${dotDuration}"
5450
- data-wipe-ratio="1"
5451
- data-syllable-index="0"
5452
- ></span>
5453
- </span>
5454
- <span class="lyrics-syllable-wrap">
5455
- <span
5456
- class="lyrics-syllable"
5457
- data-start-time="${gapForLine.gapStart + dotDuration}"
5458
- data-end-time="${gapForLine.gapStart + dotDuration * 2}"
5459
- data-duration="${dotDuration}"
5460
- data-wipe-ratio="1"
5461
- data-syllable-index="1"
5462
- ></span>
5463
- </span>
5464
- <span class="lyrics-syllable-wrap">
5465
- <span
5466
- class="lyrics-syllable"
5467
- data-start-time="${gapForLine.gapStart + dotDuration * 2}"
5468
- data-end-time="${gapForLine.gapEnd}"
5469
- data-duration="${dotDuration}"
5470
- data-wipe-ratio="1"
5471
- data-syllable-index="2"
5472
- ></span>
5473
- </span>
5474
- </span>
5475
- </p>
5476
- </div>
5584
+ <p class="main-vocal-container">
5585
+ <span class="lyrics-word"
5586
+ ><span class="lyrics-syllable-wrap"
5587
+ ><span
5588
+ class="lyrics-syllable"
5589
+ data-start-time="${gapForLine.gapStart}"
5590
+ data-end-time="${gapForLine.gapStart + dotDuration}"
5591
+ data-duration="${dotDuration}"
5592
+ data-wipe-ratio="1"
5593
+ data-syllable-index="0"
5594
+ ></span></span
5595
+ ><span class="lyrics-syllable-wrap"
5596
+ ><span
5597
+ class="lyrics-syllable"
5598
+ data-start-time="${gapForLine.gapStart + dotDuration}"
5599
+ data-end-time="${gapForLine.gapStart + dotDuration * 2}"
5600
+ data-duration="${dotDuration}"
5601
+ data-wipe-ratio="1"
5602
+ data-syllable-index="1"
5603
+ ></span></span
5604
+ ><span class="lyrics-syllable-wrap"
5605
+ ><span
5606
+ class="lyrics-syllable"
5607
+ data-start-time="${gapForLine.gapStart + dotDuration * 2}"
5608
+ data-end-time="${gapForLine.gapEnd}"
5609
+ data-duration="${dotDuration}"
5610
+ data-wipe-ratio="1"
5611
+ data-syllable-index="2"
5612
+ ></span></span
5613
+ ></span>
5614
+ </p>
5477
5615
  </div>`;
5478
5616
  }
5479
5617
 
@@ -5483,7 +5621,7 @@ export class AmLyrics extends LitElement {
5483
5621
  id="${lineId}"
5484
5622
  class="lyrics-line ${line.alignment === 'end'
5485
5623
  ? 'singer-right'
5486
- : 'singer-left'}"
5624
+ : 'singer-left'} ${lineIsRTL ? 'rtl-text' : ''}"
5487
5625
  data-start-time="${lineStartTime}"
5488
5626
  data-end-time="${lineEndTime}"
5489
5627
  @click=${() => this.handleLineClick(line)}
@@ -5494,11 +5632,11 @@ export class AmLyrics extends LitElement {
5494
5632
  }
5495
5633
  }}
5496
5634
  >
5497
- <div class="lyrics-line-container">
5635
+ <div class="lyrics-line-container ${lineIsRTL ? 'rtl-text' : ''}">
5498
5636
  ${bgPlacement === 'before' ? backgroundVocalElement : ''}
5499
5637
  ${mainVocalElement}
5500
5638
  ${bgPlacement === 'after' ? backgroundVocalElement : ''}
5501
- ${translationElement} ${lineRomanizationElement}
5639
+ ${lineRomanizationElement} ${translationElement}
5502
5640
  </div>
5503
5641
  </div>
5504
5642
  `;
@@ -5612,13 +5750,13 @@ export class AmLyrics extends LitElement {
5612
5750
  ${renderContent()}
5613
5751
  ${!this.isLoading
5614
5752
  ? html`
5615
- <footer class="lyrics-footer">
5753
+ <footer class="lyrics-footer lyrics-line">
5616
5754
  <div class="footer-content">
5617
5755
  <span
5618
5756
  class="source-info"
5619
5757
  style="display: flex; align-items: center; gap: 8px;"
5620
5758
  >
5621
- Source: ${sourceLabel}
5759
+ <b style="font-weight: 750;">Source</b> ${sourceLabel}
5622
5760
  ${(this.availableSources &&
5623
5761
  this.availableSources.length > 1) ||
5624
5762
  !this.hasFetchedAllProviders
@@ -5661,15 +5799,25 @@ export class AmLyrics extends LitElement {
5661
5799
  `
5662
5800
  : ''}
5663
5801
  </span>
5664
- <span class="version-info">
5665
- v${VERSION}
5802
+ ${this.songwriters
5803
+ ? html`<span
5804
+ class="songwriters-info"
5805
+ style="margin-top: 4px; font-weight: normal; font-size: 0.9em;"
5806
+ >
5807
+ <b style="font-weight: 750;">Songwriters</b> ${this
5808
+ .songwriters}
5809
+ </span>`
5810
+ : ''}
5811
+ <span class="version-info" style="margin-top: 8px;">
5812
+ <b style="font-weight: 750;">am-lyrics</b> v${VERSION} •
5666
5813
 
5667
5814
  <a
5668
5815
  href="https://github.com/uimaxbai/apple-music-web-components"
5669
5816
  target="_blank"
5670
5817
  rel="noopener noreferrer"
5671
- >Star me on GitHub</a
5672
- >
5818
+ style="display: inline-flex; align-items: center; gap: 4px;"
5819
+ >Star me on GitHub
5820
+ </a>
5673
5821
  </span>
5674
5822
  </div>
5675
5823
  </footer>