@uimaxbai/am-lyrics 1.5.3 → 1.5.5

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.5.3';
5
+ const VERSION = '1.5.5';
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;
@@ -11,6 +11,15 @@ const GAP_PULSE_DURATION_MS = 4000;
11
11
  const GAP_PULSE_CYCLE_MS = GAP_PULSE_DURATION_MS * 2;
12
12
  const GAP_EXIT_LEAD_MS = 600;
13
13
  const GAP_MIN_SCALE = 0.85;
14
+ const NEXT_WORD_PRE_WIPE_MAX_GAP_MS = 180;
15
+ const NEXT_WORD_PRE_WIPE_MIN_DURATION_MS = 80;
16
+ const NEXT_WORD_PRE_WIPE_MAX_DURATION_MS = 240;
17
+ const BASE_WIPE_GRADIENT_EM = 0.75;
18
+ const LONG_WORD_WIPE_EXTRA_EM = 0.45;
19
+ const LONG_WORD_WIPE_EXTRA_RATIO = 0.35;
20
+ const SHORT_WORD_DRAG_MIN_DURATION_MS = 760;
21
+ const SHORT_WORD_GLOW_MIN_DURATION_MS = 1320;
22
+ const WORD_PRE_WIPE_HANDOFF_LEAD_MS = 120;
14
23
 
15
24
  /**
16
25
  * Fetch with an automatic timeout via AbortSignal.
@@ -105,9 +114,6 @@ interface ResolvedMetadata {
105
114
 
106
115
  export class AmLyrics extends LitElement {
107
116
  static styles = css`
108
- /* ==========================================================================
109
- YOULYPLUS-INSPIRED STYLING - Design Tokens & Variables
110
- ========================================================================== */
111
117
  :host {
112
118
  --lyplus-lyrics-palette: var(
113
119
  --am-lyrics-highlight-color,
@@ -136,6 +142,8 @@ export class AmLyrics extends LitElement {
136
142
  --lyplus-blur-amount: 0.07em;
137
143
  --lyplus-blur-amount-near: 0.035em;
138
144
  --lyplus-fade-gap-timing-function: ease-out;
145
+ --wipe-gradient-width: 0.75em;
146
+ --wipe-gradient-half: 0.375em;
139
147
 
140
148
  --lyrics-scroll-padding-top: 25%;
141
149
 
@@ -163,6 +171,9 @@ export class AmLyrics extends LitElement {
163
171
  max-height: 100vh;
164
172
  overflow-y: auto;
165
173
  -webkit-overflow-scrolling: touch;
174
+ -webkit-touch-callout: none;
175
+ -webkit-user-select: none;
176
+ user-select: none;
166
177
  box-sizing: border-box;
167
178
  scrollbar-width: none;
168
179
  overflow-anchor: none;
@@ -259,17 +270,15 @@ export class AmLyrics extends LitElement {
259
270
 
260
271
  .background-vocal-container {
261
272
  max-height: 0;
262
- overflow: visible;
273
+ overflow: hidden;
263
274
  opacity: 0;
264
275
  font-size: var(--lyplus-font-size-subtext);
265
276
  line-height: 1.15;
266
277
  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. */
270
278
  transition:
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);
279
+ max-height var(--scroll-duration, 400ms)
280
+ cubic-bezier(0.41, 0, 0.12, 0.99),
281
+ opacity var(--scroll-duration, 400ms) cubic-bezier(0.41, 0, 0.12, 0.99);
273
282
  margin: 0;
274
283
  pointer-events: none;
275
284
  }
@@ -278,7 +287,8 @@ export class AmLyrics extends LitElement {
278
287
  display: block;
279
288
  padding-top: 0;
280
289
  padding-bottom: 0;
281
- transition: padding-top 250ms cubic-bezier(0.41, 0, 0.12, 0.99);
290
+ transition: padding-top var(--scroll-duration, 400ms)
291
+ cubic-bezier(0.41, 0, 0.12, 0.99);
282
292
  }
283
293
 
284
294
  .lyrics-line.singer-right .background-vocal-container,
@@ -293,16 +303,11 @@ export class AmLyrics extends LitElement {
293
303
  .lyrics-line.bg-expanded .background-vocal-container {
294
304
  max-height: 4em;
295
305
  opacity: 1;
296
- /* Slower entry (0.6 s) so bg vocals expand smoothly. */
297
- transition:
298
- max-height 0.6s ease,
299
- opacity 0.6s ease;
300
- will-change: max-height, opacity;
306
+ will-change: opacity;
301
307
  }
302
308
 
303
309
  .lyrics-line.bg-expanded .background-vocal-wrap {
304
310
  padding-top: 0.26em;
305
- transition: padding-top 0.6s ease;
306
311
  }
307
312
 
308
313
  /* --- Line States & Modifiers --- */
@@ -450,11 +455,22 @@ export class AmLyrics extends LitElement {
450
455
  white-space: nowrap;
451
456
  }
452
457
 
458
+ .lyrics-word.char-drag {
459
+ display: inline-block;
460
+ vertical-align: baseline;
461
+ white-space: nowrap;
462
+ }
463
+
453
464
  .lyrics-word.char-rise.allow-break {
454
465
  display: inline;
455
466
  white-space: normal;
456
467
  }
457
468
 
469
+ .lyrics-word.char-drag.allow-break {
470
+ display: inline;
471
+ white-space: normal;
472
+ }
473
+
458
474
  .lyrics-syllable-wrap {
459
475
  display: inline;
460
476
  }
@@ -509,44 +525,30 @@ export class AmLyrics extends LitElement {
509
525
  .lyrics-line.active:not(.lyrics-gap)
510
526
  .lyrics-syllable.pre-highlight.no-chars {
511
527
  background-repeat: no-repeat;
512
- background-image:
513
- linear-gradient(
514
- 90deg,
515
- #ffffff00 0%,
516
- var(--lyplus-text-primary, #fff) 50%,
517
- #0000 100%
518
- ),
519
- linear-gradient(
520
- 90deg,
521
- var(--lyplus-text-primary, #fff) 100%,
522
- #0000 100%
523
- );
524
- background-size:
525
- 0.5em 100%,
526
- 0% 100%;
527
- background-position:
528
- -0.5em 0%,
529
- -0.25em 0%;
528
+ background-image: linear-gradient(
529
+ 90deg,
530
+ var(--lyplus-text-primary, #fff) 0%,
531
+ var(--lyplus-text-primary, #fff)
532
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
533
+ #0000 100%
534
+ );
535
+ background-size: 0% 100%;
536
+ background-position: left;
530
537
  }
531
538
 
532
539
  .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.highlight.rtl-text,
533
540
  .lyrics-line.active:not(.lyrics-gap)
534
541
  .lyrics-syllable.pre-highlight.rtl-text {
535
542
  direction: rtl;
536
- background-image:
537
- linear-gradient(
538
- -90deg,
539
- var(--lyplus-text-primary) 0%,
540
- transparent 100%
541
- ),
542
- linear-gradient(
543
- -90deg,
544
- var(--lyplus-text-primary) 100%,
545
- transparent 100%
546
- );
547
- background-position:
548
- calc(100% + 0.5em) 0%,
549
- right 0%;
543
+ background-image: linear-gradient(
544
+ -90deg,
545
+ var(--lyplus-text-primary) 0%,
546
+ var(--lyplus-text-primary)
547
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
548
+ transparent 100%
549
+ );
550
+ background-size: 0% 100%;
551
+ background-position: right 0%;
550
552
  }
551
553
 
552
554
  /* Background vocals: muted gray wipe instead of white.
@@ -563,18 +565,13 @@ export class AmLyrics extends LitElement {
563
565
  .lyrics-line.pre-active
564
566
  .background-vocal-container
565
567
  .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
- );
568
+ background-image: linear-gradient(
569
+ 90deg,
570
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 0%,
571
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888)
572
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
573
+ #0000 100%
574
+ );
578
575
  }
579
576
 
580
577
  .lyrics-line.active
@@ -589,17 +586,13 @@ export class AmLyrics extends LitElement {
589
586
  .lyrics-line.pre-active
590
587
  .background-vocal-container
591
588
  .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
- );
589
+ background-image: linear-gradient(
590
+ -90deg,
591
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 0%,
592
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888)
593
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
594
+ transparent 100%
595
+ );
603
596
  }
604
597
 
605
598
  /* Non-growable words float up with a gentle curve */
@@ -623,6 +616,10 @@ export class AmLyrics extends LitElement {
623
616
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
624
617
  }
625
618
 
619
+ .lyrics-word.char-drag .lyrics-syllable.cleanup .char {
620
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
621
+ }
622
+
626
623
  .lyrics-line.persist-highlight
627
624
  .lyrics-word.growable
628
625
  .lyrics-syllable.finished
@@ -630,6 +627,10 @@ export class AmLyrics extends LitElement {
630
627
  .lyrics-line.persist-highlight
631
628
  .lyrics-word.char-rise
632
629
  .lyrics-syllable.finished
630
+ .char,
631
+ .lyrics-line.persist-highlight
632
+ .lyrics-word.char-drag
633
+ .lyrics-syllable.finished
633
634
  .char {
634
635
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
635
636
  }
@@ -740,70 +741,56 @@ export class AmLyrics extends LitElement {
740
741
  transform 0.7s ease;
741
742
  }
742
743
 
744
+ .lyrics-word.char-drag span.char {
745
+ transition: color 0.18s;
746
+ }
747
+
743
748
  /* Active char spans: structural only, wipe animation sets gradient */
744
749
  .lyrics-line.active .lyrics-syllable span.char {
745
750
  background-clip: text;
746
751
  -webkit-background-clip: text;
747
752
  background-repeat: no-repeat;
748
- background-image:
749
- linear-gradient(
750
- 90deg,
751
- #ffffff00 0%,
752
- var(--lyplus-text-primary, #fff) 50%,
753
- #0000 100%
754
- ),
755
- linear-gradient(
756
- 90deg,
757
- var(--lyplus-text-primary, #fff) 100%,
758
- #0000 100%
759
- );
760
- background-size:
761
- 0.5em 100%,
762
- 0% 100%;
763
- background-position:
764
- -0.5em 0%,
765
- -0.25em 0%;
753
+ background-image: linear-gradient(
754
+ 90deg,
755
+ var(--lyplus-text-primary, #fff) 0%,
756
+ var(--lyplus-text-primary, #fff)
757
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
758
+ #0000 100%
759
+ );
760
+ background-size: 0% 100%;
761
+ background-position: left;
766
762
  transition:
767
763
  transform 0.7s ease,
768
764
  color 0.18s;
769
765
  }
770
766
 
771
767
  .lyrics-line.active .lyrics-syllable span.char.highlight {
772
- background-image:
773
- linear-gradient(
774
- -90deg,
775
- var(--lyplus-text-primary, #fff) 0%,
776
- #0000 100%
777
- ),
778
- linear-gradient(
779
- -90deg,
780
- var(--lyplus-text-primary, #fff) 100%,
781
- #0000 100%
782
- );
783
- background-position:
784
- calc(100% + 0.5em) 0%,
785
- calc(100% + 0.25em) 0%;
786
- }
787
-
788
- .lyrics-line.active .lyrics-syllable.pre-highlight span.char {
789
- background-image:
790
- linear-gradient(
791
- 90deg,
792
- #ffffff00 0%,
793
- var(--lyplus-text-primary, #fff) 50%,
794
- #0000 100%
795
- ),
796
- linear-gradient(
797
- 90deg,
798
- var(--lyplus-text-primary, #fff) 100%,
799
- #0000 100%
800
- );
801
- background-size:
802
- 0.75em 100%,
803
- 0% 100%;
804
- background-position:
805
- -0.85em 0%,
806
- -0.25em 0%;
768
+ background-image: linear-gradient(
769
+ -90deg,
770
+ var(--lyplus-text-primary, #fff) 0%,
771
+ var(--lyplus-text-primary, #fff)
772
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
773
+ #0000 100%
774
+ );
775
+ background-size: 0% 100%;
776
+ background-position: right 0%;
777
+ }
778
+
779
+ .lyrics-line.active .lyrics-syllable span.char.pre-wipe-lead {
780
+ animation-name: pre-wipe-word-char;
781
+ animation-duration: var(--pre-wipe-duration);
782
+ animation-delay: var(--pre-wipe-delay);
783
+ animation-timing-function: linear;
784
+ animation-fill-mode: forwards;
785
+ background-image: linear-gradient(
786
+ 90deg,
787
+ var(--lyplus-text-primary, #fff) 0%,
788
+ var(--lyplus-text-primary, #fff)
789
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
790
+ #0000 100%
791
+ );
792
+ background-size: 0% 100%;
793
+ background-position: var(--char-wipe-position, left) 0%;
807
794
  }
808
795
 
809
796
  /* ==========================================================================
@@ -880,13 +867,7 @@ export class AmLyrics extends LitElement {
880
867
  color: var(--lyplus-text-primary) !important;
881
868
  }
882
869
 
883
- .lyrics-line.pre-active .lyrics-syllable.line-synced {
884
- animation: fade-in-line 0.14s ease-out forwards !important;
885
- color: var(--lyplus-text-primary) !important;
886
- }
887
-
888
- .lyrics-line.active .lyrics-syllable.line-synced span.char,
889
- .lyrics-line.pre-active .lyrics-syllable.line-synced span.char {
870
+ .lyrics-line.active .lyrics-syllable.line-synced span.char {
890
871
  background-image: none !important;
891
872
  background-color: var(--lyplus-text-primary) !important;
892
873
  transition: background-color 120ms ease-out !important;
@@ -1059,6 +1040,49 @@ export class AmLyrics extends LitElement {
1059
1040
  gap: 4px;
1060
1041
  }
1061
1042
 
1043
+ .source-switch-btn {
1044
+ position: relative;
1045
+ display: inline-flex;
1046
+ align-items: center;
1047
+ padding: 2px 8px;
1048
+ border: 1px solid rgba(255, 255, 255, 0.2);
1049
+ min-height: 28px;
1050
+ background: transparent;
1051
+ border-radius: 6px;
1052
+ color: #aaa;
1053
+ cursor: pointer;
1054
+ font-family: inherit;
1055
+ font-size: 11px;
1056
+ transition:
1057
+ color 0.2s ease,
1058
+ border-color 0.2s ease,
1059
+ background-color 0.2s ease,
1060
+ transform 0.12s ease;
1061
+ }
1062
+
1063
+ .source-switch-btn::before {
1064
+ content: '';
1065
+ position: absolute;
1066
+ inset: -6px;
1067
+ }
1068
+
1069
+ .source-switch-btn:active:not(:disabled) {
1070
+ transform: scale(0.96);
1071
+ }
1072
+
1073
+ .source-switch-btn:disabled {
1074
+ cursor: default;
1075
+ opacity: 0.7;
1076
+ }
1077
+
1078
+ .source-switch-svg {
1079
+ margin-right: 4px;
1080
+ }
1081
+
1082
+ .source-switch-svg.is-loading {
1083
+ animation: source-switch-spin 1s linear infinite;
1084
+ }
1085
+
1062
1086
  .control-button {
1063
1087
  background: transparent;
1064
1088
  border: 1px solid rgba(255, 255, 255, 0.3);
@@ -1067,7 +1091,10 @@ export class AmLyrics extends LitElement {
1067
1091
  font-size: 0.8em;
1068
1092
  color: rgba(255, 255, 255, 0.6);
1069
1093
  cursor: pointer;
1070
- transition: all 0.2s;
1094
+ transition:
1095
+ color 0.2s,
1096
+ border-color 0.2s,
1097
+ background-color 0.2s;
1071
1098
  font-weight: normal;
1072
1099
  }
1073
1100
 
@@ -1205,137 +1232,125 @@ export class AmLyrics extends LitElement {
1205
1232
  KEYFRAME ANIMATIONS
1206
1233
  ========================================================================== */
1207
1234
 
1235
+ @keyframes source-switch-spin {
1236
+ to {
1237
+ transform: rotate(360deg);
1238
+ }
1239
+ }
1240
+
1208
1241
  /* Wipe animation for syllables */
1209
1242
  @keyframes wipe {
1210
1243
  from {
1211
- background-size:
1212
- 0.75em 100%,
1213
- 0% 100%;
1214
- background-position:
1215
- -0.375em 0%,
1216
- left;
1244
+ background-size: 0% 100%;
1245
+ background-position: left;
1246
+ }
1247
+ to {
1248
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1249
+ background-position: left;
1250
+ }
1251
+ }
1252
+
1253
+ @keyframes wipe-from-pre {
1254
+ from {
1255
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
1256
+ background-position: left;
1217
1257
  }
1218
1258
  to {
1219
- background-size:
1220
- 0.75em 100%,
1221
- 100% 100%;
1222
- background-position:
1223
- calc(100% + 0.375em) 0%,
1224
- left;
1259
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1260
+ background-position: left;
1225
1261
  }
1226
1262
  }
1227
1263
 
1228
1264
  @keyframes start-wipe {
1229
1265
  0% {
1230
- background-size:
1231
- 0.75em 100%,
1232
- 0% 100%;
1233
- background-position:
1234
- -0.75em 0%,
1235
- -0.375em 0%;
1266
+ background-size: 0% 100%;
1267
+ background-position: left;
1236
1268
  }
1237
1269
  100% {
1238
- background-size:
1239
- 0.75em 100%,
1240
- 100% 100%;
1241
- background-position:
1242
- calc(100% + 0.375em) 0%,
1243
- left;
1270
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1271
+ background-position: left;
1244
1272
  }
1245
1273
  }
1246
1274
 
1247
1275
  @keyframes wipe-rtl {
1248
1276
  from {
1249
- background-size:
1250
- 0.75em 100%,
1251
- 0% 100%;
1252
- background-position:
1253
- calc(100% + 0.375em) 0%,
1254
- calc(100% + 0.36em) 0%;
1277
+ background-size: 0% 100%;
1278
+ background-position: right 0%;
1279
+ }
1280
+ to {
1281
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1282
+ background-position: right 0%;
1283
+ }
1284
+ }
1285
+
1286
+ @keyframes wipe-from-pre-rtl {
1287
+ from {
1288
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
1289
+ background-position: right 0%;
1255
1290
  }
1256
1291
  to {
1257
- background-size:
1258
- 0.75em 100%,
1259
- 100% 100%;
1260
- background-position:
1261
- -0.75em 0%,
1262
- right 0%;
1292
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1293
+ background-position: right 0%;
1263
1294
  }
1264
1295
  }
1265
1296
 
1266
1297
  @keyframes start-wipe-rtl {
1267
1298
  0% {
1268
- background-size:
1269
- 0.75em 100%,
1270
- 0% 100%;
1271
- background-position:
1272
- calc(100% + 0.75em) 0%,
1273
- calc(100% + 0.5em) 0%;
1299
+ background-size: 0% 100%;
1300
+ background-position: right 0%;
1274
1301
  }
1275
1302
  100% {
1276
- background-size:
1277
- 0.75em 100%,
1278
- 100% 100%;
1279
- background-position:
1280
- -0.75em 0%,
1281
- right 0%;
1303
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1304
+ background-position: right 0%;
1282
1305
  }
1283
1306
  }
1284
1307
 
1285
1308
  @keyframes pre-wipe-universal {
1286
1309
  from {
1287
- background-size:
1288
- 0.75em 100%,
1289
- 0% 100%;
1290
- background-position:
1291
- -0.75em 0%,
1292
- left;
1310
+ background-size: 0% 100%;
1311
+ background-position: left;
1293
1312
  }
1294
1313
  to {
1295
- background-size:
1296
- 0.75em 100%,
1297
- 0% 100%;
1298
- background-position:
1299
- -0.375em 0%,
1300
- left;
1314
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
1315
+ background-position: left;
1301
1316
  }
1302
1317
  }
1303
1318
 
1304
1319
  @keyframes pre-wipe-universal-rtl {
1305
1320
  from {
1306
- background-size:
1307
- 0.75em 100%,
1308
- 0% 100%;
1309
- background-position:
1310
- calc(100% + 0.75em) 0%,
1311
- right 0%;
1321
+ background-size: 0% 100%;
1322
+ background-position: right 0%;
1323
+ }
1324
+ to {
1325
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
1326
+ background-position: right 0%;
1327
+ }
1328
+ }
1329
+
1330
+ @keyframes pre-wipe-word-char {
1331
+ from {
1332
+ background-size: 0px 100%;
1333
+ background-position: var(--char-wipe-position, left) 0%;
1312
1334
  }
1313
1335
  to {
1314
- background-size:
1315
- 0.75em 100%,
1316
- 0% 100%;
1317
- background-position:
1318
- calc(100% + 0.375em) 0%,
1319
- right 0%;
1336
+ background-size: var(--pre-wipe-word-size, var(--wipe-gradient-width))
1337
+ 100%;
1338
+ background-position: var(--char-wipe-position, left) 0%;
1320
1339
  }
1321
1340
  }
1322
1341
 
1323
- @keyframes pre-wipe-char {
1342
+ @keyframes wipe-word-from-pre {
1324
1343
  from {
1325
- background-size:
1326
- 0.75em 100%,
1327
- 0% 100%;
1328
- background-position:
1329
- -0.75em 0%,
1330
- left;
1344
+ background-size: var(--pre-wipe-word-size, var(--wipe-gradient-width))
1345
+ 100%;
1346
+ background-position: var(--char-wipe-position, left) 0%;
1331
1347
  }
1332
1348
  to {
1333
- background-size:
1334
- 0.75em 100%,
1335
- 0% 100%;
1336
- background-position:
1337
- -0.375em 0%,
1338
- left;
1349
+ background-size: calc(
1350
+ var(--word-wipe-width, 100%) + var(--wipe-gradient-width, 0.75em)
1351
+ )
1352
+ 100%;
1353
+ background-position: var(--char-wipe-position, left) 0%;
1339
1354
  }
1340
1355
  }
1341
1356
 
@@ -1429,6 +1444,15 @@ export class AmLyrics extends LitElement {
1429
1444
  }
1430
1445
  }
1431
1446
 
1447
+ @keyframes drag-char {
1448
+ 0% {
1449
+ transform: translate3d(0, 0, 0);
1450
+ }
1451
+ 100% {
1452
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
1453
+ }
1454
+ }
1455
+
1432
1456
  @keyframes grow-static {
1433
1457
  0%,
1434
1458
  100% {
@@ -1674,6 +1698,7 @@ export class AmLyrics extends LitElement {
1674
1698
  this.preActiveLineElements = [];
1675
1699
  this.positionedLineElements = [];
1676
1700
  this.activeGapLineElements = [];
1701
+ this.clearBackgroundExpandedLine();
1677
1702
 
1678
1703
  // Stop all running animations and clear highlights immediately
1679
1704
  if (this.lyricsContainer) {
@@ -1746,10 +1771,7 @@ export class AmLyrics extends LitElement {
1746
1771
  (switchBtn as HTMLButtonElement).disabled = this.isFetchingAlternatives;
1747
1772
  }
1748
1773
  if (svgEl) {
1749
- svgEl.setAttribute(
1750
- 'style',
1751
- `margin-right: 4px; ${this.isFetchingAlternatives ? 'animation: spin 1s linear infinite;' : ''}`,
1752
- );
1774
+ svgEl.classList.toggle('is-loading', this.isFetchingAlternatives);
1753
1775
  }
1754
1776
  if (labelEl) {
1755
1777
  labelEl.textContent = this.isFetchingAlternatives
@@ -1812,6 +1834,7 @@ export class AmLyrics extends LitElement {
1812
1834
  groupGrowable: boolean[];
1813
1835
  groupGlowing: boolean[];
1814
1836
  groupCharRise: boolean[];
1837
+ groupCharDrag: boolean[];
1815
1838
  vwFullText: string[];
1816
1839
  vwFullDuration: number[];
1817
1840
  vwCharOffset: number[];
@@ -1827,6 +1850,8 @@ export class AmLyrics extends LitElement {
1827
1850
 
1828
1851
  private lastPrimaryActiveLine: HTMLElement | null = null;
1829
1852
 
1853
+ private backgroundExpandedLine: HTMLElement | null = null;
1854
+
1830
1855
  // Scroll animation state
1831
1856
  private scrollAnimationState: {
1832
1857
  isAnimating: boolean;
@@ -2082,6 +2107,7 @@ export class AmLyrics extends LitElement {
2082
2107
  this.preActiveLineElements = [];
2083
2108
  this.positionedLineElements = [];
2084
2109
  this.activeGapLineElements = [];
2110
+ this.clearBackgroundExpandedLine();
2085
2111
 
2086
2112
  if (this.lyricsContainer) {
2087
2113
  this.isProgrammaticScroll = true;
@@ -2146,20 +2172,26 @@ export class AmLyrics extends LitElement {
2146
2172
  return 30;
2147
2173
  }
2148
2174
 
2175
+ private static getDisplaySourceLabel(sourceLabel: string): string {
2176
+ return sourceLabel.toLowerCase().includes('lyricsplus')
2177
+ ? 'QQ'
2178
+ : sourceLabel;
2179
+ }
2180
+
2181
+ private static getSourceKey(sourceLabel: string | null | undefined): string {
2182
+ const lower = (sourceLabel || '').trim().toLowerCase();
2183
+ if (!lower) return '';
2184
+ if (lower.includes('lyricsplus') || lower === 'qq') return 'qq';
2185
+ return lower.replace(/\s+/g, ' ');
2186
+ }
2187
+
2149
2188
  private static mergeAndSortSources(
2150
- collectedSources: { lines: LyricsLine[]; source: string }[],
2151
- ): { lines: LyricsLine[]; source: string }[] {
2152
- const uniqueSourcesMap = new Map<
2153
- string,
2154
- { lines: LyricsLine[]; source: string }
2155
- >();
2189
+ collectedSources: YouLyPlusLyricsResult[],
2190
+ ): YouLyPlusLyricsResult[] {
2191
+ const uniqueSourcesMap = new Map<string, YouLyPlusLyricsResult>();
2156
2192
 
2157
2193
  for (const source of collectedSources) {
2158
- const normalizedSource = source.source
2159
- .toLowerCase()
2160
- .includes('lyricsplus')
2161
- ? 'QQ'
2162
- : source.source;
2194
+ const normalizedSource = AmLyrics.getDisplaySourceLabel(source.source);
2163
2195
 
2164
2196
  if (!uniqueSourcesMap.has(normalizedSource)) {
2165
2197
  uniqueSourcesMap.set(normalizedSource, {
@@ -2176,9 +2208,63 @@ export class AmLyrics extends LitElement {
2176
2208
  );
2177
2209
  }
2178
2210
 
2211
+ private findCurrentSourceIndex(
2212
+ sources = this.availableSources,
2213
+ sourceLabel = this.lyricsSource,
2214
+ lines = this.lyrics,
2215
+ ): number {
2216
+ const identityIndex = sources.findIndex(source => source.lines === lines);
2217
+ if (identityIndex !== -1) return identityIndex;
2218
+
2219
+ const sourceKey = AmLyrics.getSourceKey(sourceLabel);
2220
+ if (!sourceKey) return -1;
2221
+
2222
+ return sources.findIndex(
2223
+ source => AmLyrics.getSourceKey(source.source) === sourceKey,
2224
+ );
2225
+ }
2226
+
2227
+ private static getNextSourceIndex(
2228
+ sources: YouLyPlusLyricsResult[],
2229
+ currentIndex: number,
2230
+ currentSourceLabel: string | null,
2231
+ currentLines: LyricsLine[] | undefined,
2232
+ ): number {
2233
+ if (sources.length <= 1) return -1;
2234
+
2235
+ if (currentIndex !== -1) {
2236
+ return (currentIndex + 1) % sources.length;
2237
+ }
2238
+
2239
+ const currentKey = AmLyrics.getSourceKey(currentSourceLabel);
2240
+ const fallbackIndex = sources.findIndex(
2241
+ source =>
2242
+ source.lines !== currentLines &&
2243
+ AmLyrics.getSourceKey(source.source) !== currentKey,
2244
+ );
2245
+
2246
+ return fallbackIndex === -1 ? 0 : fallbackIndex;
2247
+ }
2248
+
2249
+ private async applySourceAtIndex(index: number) {
2250
+ const sourceResult = this.availableSources[index];
2251
+ if (!sourceResult) return;
2252
+
2253
+ this.currentSourceIndex = index;
2254
+ this.lyrics = sourceResult.lines;
2255
+ this.lyricsSource = sourceResult.source;
2256
+ if (sourceResult.songwriters) {
2257
+ this.songwriters = sourceResult.songwriters;
2258
+ }
2259
+ await this.onLyricsLoaded();
2260
+ }
2261
+
2179
2262
  private async switchSource() {
2180
2263
  if (this.isFetchingAlternatives) return;
2181
2264
 
2265
+ const currentSourceLabel = this.lyricsSource;
2266
+ const currentLines = this.lyrics;
2267
+
2182
2268
  if (!this.hasFetchedAllProviders) {
2183
2269
  this.isFetchingAlternatives = true;
2184
2270
  this._updateFooter();
@@ -2255,11 +2341,13 @@ export class AmLyrics extends LitElement {
2255
2341
  ...this.availableSources,
2256
2342
  ...newSources,
2257
2343
  ]);
2258
- // Re-sync current index since sorting might shift elements
2259
- this.currentSourceIndex = this.availableSources.findIndex(
2260
- s => s.source === this.lyricsSource,
2344
+ // Re-sync current index since sorting or label normalization can
2345
+ // shift the currently displayed source underneath the old index.
2346
+ this.currentSourceIndex = this.findCurrentSourceIndex(
2347
+ this.availableSources,
2348
+ currentSourceLabel,
2349
+ currentLines,
2261
2350
  );
2262
- if (this.currentSourceIndex === -1) this.currentSourceIndex = 0;
2263
2351
  }
2264
2352
  }
2265
2353
  } finally {
@@ -2270,15 +2358,20 @@ export class AmLyrics extends LitElement {
2270
2358
  }
2271
2359
 
2272
2360
  if (this.availableSources.length > 1) {
2273
- this.currentSourceIndex =
2274
- (this.currentSourceIndex + 1) % this.availableSources.length;
2275
- const sourceResult = this.availableSources[this.currentSourceIndex];
2276
- this.lyrics = sourceResult.lines;
2277
- this.lyricsSource = sourceResult.source;
2278
- if (sourceResult.songwriters) {
2279
- this.songwriters = sourceResult.songwriters;
2361
+ const currentIndex = this.findCurrentSourceIndex(
2362
+ this.availableSources,
2363
+ currentSourceLabel,
2364
+ currentLines,
2365
+ );
2366
+ const nextIndex = AmLyrics.getNextSourceIndex(
2367
+ this.availableSources,
2368
+ currentIndex,
2369
+ currentSourceLabel,
2370
+ currentLines,
2371
+ );
2372
+ if (nextIndex !== -1) {
2373
+ await this.applySourceAtIndex(nextIndex);
2280
2374
  }
2281
- await this.onLyricsLoaded();
2282
2375
  }
2283
2376
  }
2284
2377
 
@@ -3544,7 +3637,11 @@ export class AmLyrics extends LitElement {
3544
3637
  if (!newActiveLines.includes(lineIndex)) {
3545
3638
  const lineElement = this._getLineElement(lineIndex);
3546
3639
  if (lineElement) {
3547
- if (isSeek || this.isUserScrolling) {
3640
+ if (
3641
+ isSeek ||
3642
+ this.isUserScrolling ||
3643
+ AmLyrics.isLineSyncedLine(this.lyrics?.[lineIndex])
3644
+ ) {
3548
3645
  AmLyrics.unfinishSyllables(lineElement);
3549
3646
  } else {
3550
3647
  AmLyrics.finishSyllablesUpToTime(lineElement, newTime);
@@ -3561,12 +3658,13 @@ export class AmLyrics extends LitElement {
3561
3658
  }
3562
3659
  }
3563
3660
 
3564
- // Add 'active' and 'bg-expanded' to newly active lines
3661
+ // Add 'active' to newly active lines. Background expansion is driven
3662
+ // separately by the current scroll target.
3565
3663
  for (const lineIndex of newActiveLines) {
3566
3664
  if (!oldActiveLines.includes(lineIndex)) {
3567
3665
  const lineElement = this._getLineElement(lineIndex);
3568
3666
  if (lineElement) {
3569
- lineElement.classList.add('active', 'bg-expanded');
3667
+ lineElement.classList.add('active');
3570
3668
  lineElement.classList.remove('pre-active');
3571
3669
  const preIdx = this.preActiveLineElements.indexOf(lineElement);
3572
3670
  if (preIdx !== -1) this.preActiveLineElements.splice(preIdx, 1);
@@ -3830,8 +3928,14 @@ export class AmLyrics extends LitElement {
3830
3928
  const activeLines = this.findActiveLineIndices(this.currentTime);
3831
3929
  for (const lineIndex of activeLines) {
3832
3930
  const lineEl = this._getLineElement(lineIndex);
3833
- if (lineEl) lineEl.classList.add('active', 'bg-expanded');
3931
+ if (lineEl) lineEl.classList.add('active');
3834
3932
  }
3933
+ const primaryActiveLine = this.getPrimaryActiveLineIndex(activeLines);
3934
+ this.setBackgroundExpandedLine(
3935
+ primaryActiveLine !== null
3936
+ ? this._getLineElement(primaryActiveLine)
3937
+ : null,
3938
+ );
3835
3939
 
3836
3940
  // Trigger a faux time-change so that updateSyllablesForLine fires
3837
3941
  // to setup inline syllable CSS wipe animations for whatever the current time is
@@ -3878,6 +3982,7 @@ export class AmLyrics extends LitElement {
3878
3982
  this.preActiveLineElements = [];
3879
3983
  this.positionedLineElements = [];
3880
3984
  this.activeGapLineElements = [];
3985
+ this.clearBackgroundExpandedLine();
3881
3986
  this.setUserScrolling(false);
3882
3987
 
3883
3988
  // Cancel any running animations
@@ -3949,6 +4054,7 @@ export class AmLyrics extends LitElement {
3949
4054
  // Don't override it with a scroll back to the last lyric.
3950
4055
  const footer = this.lyricsContainer.querySelector('.lyrics-footer');
3951
4056
  if (footer?.classList.contains('active')) {
4057
+ this.setBackgroundExpandedLine(null);
3952
4058
  return;
3953
4059
  }
3954
4060
 
@@ -4000,10 +4106,17 @@ export class AmLyrics extends LitElement {
4000
4106
  targetElement = this._getLineElement(targetLineIdx);
4001
4107
  }
4002
4108
  }
4003
- if (!targetElement) return;
4109
+ if (!targetElement) {
4110
+ this.setBackgroundExpandedLine(null);
4111
+ return;
4112
+ }
4113
+
4114
+ const scrollDuration = scrollLookAheadMs;
4115
+ targetElement.style.setProperty('--scroll-duration', `${scrollDuration}ms`);
4116
+ this.setBackgroundExpandedLine(targetElement);
4004
4117
 
4005
- // Unblur the upcoming target line early (pre-active) so background
4006
- // vocals start their max-height/opacity transition in sync with scroll.
4118
+ // Unblur the upcoming target line early while the separate bg-expanded
4119
+ // class starts background vocal height/opacity in sync with scroll.
4007
4120
  if (!targetElement.classList.contains('active')) {
4008
4121
  targetElement.classList.add('pre-active');
4009
4122
  if (!this.preActiveLineElements.includes(targetElement)) {
@@ -4011,8 +4124,6 @@ export class AmLyrics extends LitElement {
4011
4124
  }
4012
4125
  }
4013
4126
 
4014
- const scrollDuration = scrollLookAheadMs;
4015
-
4016
4127
  this.focusLine(targetElement, forceScroll, scrollDuration);
4017
4128
  }
4018
4129
 
@@ -4102,6 +4213,7 @@ export class AmLyrics extends LitElement {
4102
4213
  this.preActiveLineElements = [];
4103
4214
  this.positionedLineElements = [];
4104
4215
  this.activeGapLineElements = [];
4216
+ this.clearBackgroundExpandedLine();
4105
4217
  this.visibilityObserver?.disconnect();
4106
4218
  this.visibilityObserver = undefined;
4107
4219
  }
@@ -4145,6 +4257,7 @@ export class AmLyrics extends LitElement {
4145
4257
  const groupGrowable: boolean[] = new Array(wordGroups.length).fill(false);
4146
4258
  const groupGlowing: boolean[] = new Array(wordGroups.length).fill(false);
4147
4259
  const groupCharRise: boolean[] = new Array(wordGroups.length).fill(false);
4260
+ const groupCharDrag: boolean[] = new Array(wordGroups.length).fill(false);
4148
4261
  const vwFullText: string[] = new Array(wordGroups.length).fill('');
4149
4262
  const vwFullDuration: number[] = new Array(wordGroups.length).fill(0);
4150
4263
  const vwCharOffset: number[] = new Array(wordGroups.length).fill(0);
@@ -4184,29 +4297,53 @@ export class AmLyrics extends LitElement {
4184
4297
  const hasHyphen = combinedText.includes('-');
4185
4298
 
4186
4299
  const wordLen = combinedText.length;
4187
- let isGrowableVW =
4188
- !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 7;
4300
+ const canAnimateByChar = !isCJK && !isRTL && !hasHyphen && wordLen > 0;
4301
+ const isLineSynced =
4302
+ line.isWordSynced === false || line.text.some(s => s.lineSynced);
4303
+ let isGrowableVW = canAnimateByChar && wordLen > 0 && wordLen <= 7;
4189
4304
  if (isGrowableVW) {
4190
- if (wordLen < 3) {
4305
+ if (wordLen <= 1) {
4191
4306
  isGrowableVW =
4192
4307
  combinedDuration >= 1050 && combinedDuration >= wordLen * 525;
4308
+ } else if (wordLen <= 3) {
4309
+ isGrowableVW =
4310
+ combinedDuration >=
4311
+ SHORT_WORD_GLOW_MIN_DURATION_MS + (wordLen - 2) * 140;
4193
4312
  } else {
4194
4313
  isGrowableVW =
4195
4314
  combinedDuration >= 850 && combinedDuration >= wordLen * 190;
4196
4315
  }
4197
4316
  }
4198
4317
 
4199
- const isLineSynced =
4200
- line.isWordSynced === false || line.text.some(s => s.lineSynced);
4201
- const isGlowingVW = isGrowableVW && !isLineSynced;
4318
+ const hasCharRiseDuration =
4319
+ combinedDuration >= Math.max(700, wordLen * 85);
4320
+ const hasTinyWordDragDuration =
4321
+ wordLen >= 2 &&
4322
+ wordLen <= 3 &&
4323
+ combinedDuration >=
4324
+ Math.max(SHORT_WORD_DRAG_MIN_DURATION_MS, wordLen * 150);
4325
+ const hasLongShortWordDuration =
4326
+ wordLen >= 4 && combinedDuration >= Math.max(1300, wordLen * 260);
4202
4327
  const isCharRiseVW =
4203
- !isGrowableVW && !isLineSynced && !isCJK && !isRTL && wordLen >= 8;
4328
+ canAnimateByChar &&
4329
+ !isLineSynced &&
4330
+ !isGrowableVW &&
4331
+ ((wordLen >= 8 && hasCharRiseDuration) ||
4332
+ (wordLen < 8 && hasLongShortWordDuration));
4333
+ const isCharDragVW =
4334
+ canAnimateByChar &&
4335
+ !isLineSynced &&
4336
+ !isGrowableVW &&
4337
+ hasTinyWordDragDuration;
4338
+
4339
+ const isGlowingVW = isGrowableVW && !isLineSynced;
4204
4340
 
4205
4341
  let charOff = 0;
4206
4342
  for (let gi = vwStart; gi <= vwEnd; gi += 1) {
4207
4343
  groupGrowable[gi] = isGrowableVW;
4208
4344
  groupGlowing[gi] = isGlowingVW;
4209
4345
  groupCharRise[gi] = isCharRiseVW;
4346
+ groupCharDrag[gi] = isCharDragVW;
4210
4347
  vwFullText[gi] = combinedText;
4211
4348
  vwFullDuration[gi] = combinedDuration;
4212
4349
  vwCharOffset[gi] = charOff;
@@ -4224,6 +4361,7 @@ export class AmLyrics extends LitElement {
4224
4361
  groupGrowable,
4225
4362
  groupGlowing,
4226
4363
  groupCharRise,
4364
+ groupCharDrag,
4227
4365
  vwFullText,
4228
4366
  vwFullDuration,
4229
4367
  vwCharOffset,
@@ -4245,62 +4383,135 @@ export class AmLyrics extends LitElement {
4245
4383
 
4246
4384
  const computedStyle = getComputedStyle(referenceSyllable);
4247
4385
  const { font } = computedStyle; // Full font string
4248
- const fontSize = parseFloat(computedStyle.fontSize);
4249
-
4250
- const charTimedWords = this.shadowRoot.querySelectorAll(
4251
- '.lyrics-word.growable, .lyrics-word.char-rise',
4252
- );
4253
- if (!charTimedWords) return;
4254
4386
 
4255
- charTimedWords.forEach((wordSpan: any) => {
4256
- const syllableWraps = wordSpan.querySelectorAll('.lyrics-syllable-wrap');
4387
+ const charTimedWords = Array.from(
4388
+ this.shadowRoot.querySelectorAll(
4389
+ '.lyrics-word.growable, .lyrics-word.char-rise, .lyrics-word.char-drag',
4390
+ ),
4391
+ ) as HTMLElement[];
4392
+ if (charTimedWords.length === 0) return;
4393
+
4394
+ const wordsByVirtualId = new Map<string, HTMLElement[]>();
4395
+ charTimedWords.forEach((wordSpan, index) => {
4396
+ const virtualWordId = wordSpan.dataset.virtualWordId || `word-${index}`;
4397
+ const words = wordsByVirtualId.get(virtualWordId);
4398
+ if (words) {
4399
+ words.push(wordSpan);
4400
+ } else {
4401
+ wordsByVirtualId.set(virtualWordId, [wordSpan]);
4402
+ }
4403
+ });
4257
4404
 
4258
- // Flatten syllables
4405
+ wordsByVirtualId.forEach(wordSpans => {
4259
4406
  const syllables: HTMLElement[] = [];
4260
- syllableWraps.forEach((wrap: HTMLElement) => {
4261
- const syl = wrap.querySelector('.lyrics-syllable');
4262
- if (syl) syllables.push(syl as HTMLElement);
4407
+ wordSpans.forEach(wordSpan => {
4408
+ const syllableWraps = wordSpan.querySelectorAll(
4409
+ '.lyrics-syllable-wrap',
4410
+ );
4411
+ syllableWraps.forEach(wrap => {
4412
+ const syl = wrap.querySelector('.lyrics-syllable');
4413
+ if (syl) syllables.push(syl as HTMLElement);
4414
+ });
4263
4415
  });
4264
4416
 
4265
- syllables.forEach(sylSpan => {
4266
- const charSpans = sylSpan.querySelectorAll('.char');
4267
- if (charSpans.length === 0) return;
4268
-
4269
- // Logic from YouLyPlus renderCharWipes:
4270
- // Use textContent from spans to ensure we measure what is rendered
4271
- const chars = Array.from(charSpans).map(span => span.textContent || '');
4272
- const charWidths = chars.map(c => this._getTextWidth(c, font));
4273
- const totalSyllableWidth = charWidths.reduce((a, b) => a + b, 0);
4274
-
4275
- const duration = parseFloat(sylSpan.dataset.duration || '0');
4276
- const velocityPxPerMs =
4277
- duration > 0 ? totalSyllableWidth / duration : 0;
4278
-
4279
- // Gradient width in pixels = 0.375 * fontSize
4280
- // This matches YouLyPlus visual gradient size
4281
- const gradientWidthPx = 0.375 * fontSize;
4282
- const gradientDurationMs =
4283
- velocityPxPerMs > 0 ? gradientWidthPx / velocityPxPerMs : 100;
4284
-
4285
- let cumulativeCharWidth = 0;
4286
-
4287
- charSpans.forEach((spanArg: any, i: number) => {
4288
- const charWidth = charWidths[i];
4289
- const span = spanArg;
4417
+ const charSpans = syllables.flatMap(syl => {
4418
+ const spans = Array.from(
4419
+ syl.querySelectorAll('.char'),
4420
+ ) as HTMLElement[];
4421
+ const target = syl as any;
4422
+ target._cachedCharSpans = spans;
4423
+ return spans;
4424
+ });
4425
+ if (charSpans.length === 0) return;
4290
4426
 
4291
- if (totalSyllableWidth > 0) {
4292
- const startPercent = cumulativeCharWidth / totalSyllableWidth;
4293
- const durationPercent = charWidth / totalSyllableWidth;
4427
+ wordSpans.forEach(wordSpan => {
4428
+ const target = wordSpan as any;
4429
+ target._cachedVirtualWordElements = wordSpans;
4430
+ target._cachedVirtualWordCharSpans = charSpans;
4431
+ });
4294
4432
 
4295
- span.dataset.wipeStart = startPercent.toFixed(4);
4296
- span.dataset.wipeDuration = durationPercent.toFixed(4);
4433
+ const syllableEntries = syllables.map(syl => {
4434
+ const spans = (syl as any)._cachedCharSpans as HTMLElement[];
4435
+ const charWidths = spans.map(span =>
4436
+ this._getTextWidth(span.textContent || '', font),
4437
+ );
4438
+ const totalWidth = charWidths.reduce((a, b) => a + b, 0);
4439
+ return {
4440
+ syl,
4441
+ spans,
4442
+ charWidths,
4443
+ totalWidth,
4444
+ start: parseFloat(syl.getAttribute('data-start-time') || ''),
4445
+ end: parseFloat(syl.getAttribute('data-end-time') || ''),
4446
+ };
4447
+ });
4448
+ const totalWordWidth = syllableEntries.reduce(
4449
+ (total, entry) => total + entry.totalWidth,
4450
+ 0,
4451
+ );
4452
+ if (totalWordWidth <= 0) return;
4297
4453
 
4298
- // The critical missing piece:
4299
- span.dataset.preWipeArrival = (duration * startPercent).toFixed(2);
4300
- span.dataset.preWipeDuration = gradientDurationMs.toFixed(2);
4454
+ const virtualWordStart = Math.min(
4455
+ ...syllableEntries
4456
+ .map(entry => entry.start)
4457
+ .filter(start => Number.isFinite(start)),
4458
+ );
4459
+ const virtualWordEnd = Math.max(
4460
+ ...syllableEntries
4461
+ .map(entry => entry.end)
4462
+ .filter(end => Number.isFinite(end)),
4463
+ );
4464
+ const virtualWordDuration = virtualWordEnd - virtualWordStart;
4465
+ const hasTimedSyllables =
4466
+ Number.isFinite(virtualWordStart) &&
4467
+ Number.isFinite(virtualWordEnd) &&
4468
+ virtualWordDuration > 0;
4469
+
4470
+ let cumulativeCharWidth = 0;
4471
+ syllableEntries.forEach(entry => {
4472
+ let cumulativeSyllableWidth = 0;
4473
+ const syllableDuration = entry.end - entry.start;
4474
+ const useSyllableTiming =
4475
+ hasTimedSyllables &&
4476
+ Number.isFinite(entry.start) &&
4477
+ Number.isFinite(entry.end) &&
4478
+ syllableDuration > 0 &&
4479
+ entry.totalWidth > 0;
4480
+
4481
+ entry.spans.forEach((span, index) => {
4482
+ const charWidth = entry.charWidths[index];
4483
+ let startPercent = cumulativeCharWidth / totalWordWidth;
4484
+ let durationPercent = charWidth / totalWordWidth;
4485
+ if (useSyllableTiming) {
4486
+ const charStartMs =
4487
+ entry.start -
4488
+ virtualWordStart +
4489
+ (cumulativeSyllableWidth / entry.totalWidth) * syllableDuration;
4490
+ const charDurationMs =
4491
+ (charWidth / entry.totalWidth) * syllableDuration;
4492
+ startPercent = AmLyrics.clamp(
4493
+ charStartMs / virtualWordDuration,
4494
+ 0,
4495
+ 1,
4496
+ );
4497
+ durationPercent = AmLyrics.clamp(
4498
+ charDurationMs / virtualWordDuration,
4499
+ 0,
4500
+ 1,
4501
+ );
4301
4502
  }
4503
+ const target = span;
4504
+
4505
+ target.dataset.wipeStart = startPercent.toFixed(4);
4506
+ target.dataset.wipeDuration = durationPercent.toFixed(4);
4507
+ target.style.setProperty('--word-wipe-width', `${totalWordWidth}px`);
4508
+ target.style.setProperty(
4509
+ '--char-wipe-position',
4510
+ `${-cumulativeCharWidth}px`,
4511
+ );
4302
4512
 
4303
4513
  cumulativeCharWidth += charWidth;
4514
+ cumulativeSyllableWidth += charWidth;
4304
4515
  });
4305
4516
  });
4306
4517
  });
@@ -4310,6 +4521,37 @@ export class AmLyrics extends LitElement {
4310
4521
  return a.length === b.length && a.every((val, i) => val === b[i]);
4311
4522
  }
4312
4523
 
4524
+ private static isLineSyncedLine(line: LyricsLine | undefined): boolean {
4525
+ if (!line) return false;
4526
+ return line.isWordSynced === false || line.text.some(s => s.lineSynced);
4527
+ }
4528
+
4529
+ private getLineHighlightEndTime(index: number): number {
4530
+ if (!this.lyrics) return 0;
4531
+ const line = this.lyrics[index];
4532
+ if (!line) return 0;
4533
+
4534
+ const rawEnd = Math.max(line.endtime, line.timestamp);
4535
+
4536
+ const nextLine = this.lyrics[index + 1];
4537
+ if (!nextLine || nextLine.timestamp <= line.timestamp) {
4538
+ return rawEnd > line.timestamp ? rawEnd + 200 : rawEnd;
4539
+ }
4540
+
4541
+ if (rawEnd > line.timestamp) {
4542
+ if (nextLine.timestamp < rawEnd) {
4543
+ return rawEnd;
4544
+ }
4545
+
4546
+ const gapToNext = nextLine.timestamp - rawEnd;
4547
+ if (gapToNext >= INSTRUMENTAL_THRESHOLD_MS) {
4548
+ return rawEnd;
4549
+ }
4550
+ }
4551
+
4552
+ return nextLine.timestamp;
4553
+ }
4554
+
4313
4555
  private static getLineIndexFromElement(
4314
4556
  lineElement: HTMLElement | null,
4315
4557
  ): number | null {
@@ -4347,6 +4589,31 @@ export class AmLyrics extends LitElement {
4347
4589
  this.preActiveLineElements = keptLines;
4348
4590
  }
4349
4591
 
4592
+ private setBackgroundExpandedLine(lineElement: HTMLElement | null): void {
4593
+ const target =
4594
+ lineElement &&
4595
+ !lineElement.classList.contains('lyrics-gap') &&
4596
+ lineElement.querySelector('.background-vocal-container')
4597
+ ? lineElement
4598
+ : null;
4599
+
4600
+ if (this.backgroundExpandedLine === target) {
4601
+ if (target && !target.classList.contains('bg-expanded')) {
4602
+ target.classList.add('bg-expanded');
4603
+ }
4604
+ return;
4605
+ }
4606
+
4607
+ this.backgroundExpandedLine?.classList.remove('bg-expanded');
4608
+ this.backgroundExpandedLine = target;
4609
+ target?.classList.add('bg-expanded');
4610
+ }
4611
+
4612
+ private clearBackgroundExpandedLine(): void {
4613
+ this.backgroundExpandedLine?.classList.remove('bg-expanded');
4614
+ this.backgroundExpandedLine = null;
4615
+ }
4616
+
4350
4617
  private getPrimaryActiveLineIndex(activeIndices: number[]): number | null {
4351
4618
  if (activeIndices.length === 0) return null;
4352
4619
 
@@ -4590,9 +4857,10 @@ export class AmLyrics extends LitElement {
4590
4857
 
4591
4858
  for (let i = 0; i < this.lyrics.length; i += 1) {
4592
4859
  const line = this.lyrics[i];
4860
+ const highlightEndTime = this.getLineHighlightEndTime(i);
4593
4861
 
4594
4862
  if (line.timestamp > time) break;
4595
- if (time >= line.timestamp && time < line.endtime) {
4863
+ if (time >= line.timestamp && time < highlightEndTime) {
4596
4864
  activeLines.push(i);
4597
4865
  }
4598
4866
  }
@@ -4859,6 +5127,7 @@ export class AmLyrics extends LitElement {
4859
5127
  this.lastPrimaryActiveLine = null;
4860
5128
  this.activeLineIds.clear();
4861
5129
  this.animatingLines = [];
5130
+ this.setBackgroundExpandedLine(null);
4862
5131
 
4863
5132
  // Find the clicked line element and scroll to it with forceScroll (like YouLyPlus)
4864
5133
  // Timestamps are already in milliseconds — match the data-start-time attribute directly
@@ -4881,6 +5150,7 @@ export class AmLyrics extends LitElement {
4881
5150
  }, 800);
4882
5151
 
4883
5152
  this.scrollToActiveLineYouLy(clickedLineElement, true);
5153
+ this.setBackgroundExpandedLine(clickedLineElement);
4884
5154
  }
4885
5155
 
4886
5156
  const event = new CustomEvent('line-click', {
@@ -5319,8 +5589,389 @@ export class AmLyrics extends LitElement {
5319
5589
 
5320
5590
  /**
5321
5591
  * Update syllable highlight animation - apply CSS wipe animation
5322
- * (Exact copy from YouLyPlus _updateSyllableAnimation)
5323
5592
  */
5593
+ private static clamp(value: number, min: number, max: number): number {
5594
+ return Math.min(max, Math.max(min, value));
5595
+ }
5596
+
5597
+ private static getVisibleCharacterCount(element: HTMLElement): number {
5598
+ const attrLength = parseFloat(
5599
+ element.getAttribute('data-word-length') || '',
5600
+ );
5601
+ if (Number.isFinite(attrLength) && attrLength > 0) return attrLength;
5602
+ return (element.textContent || '').replace(/\s/g, '').length;
5603
+ }
5604
+
5605
+ private static getLongWordWipeScale(charCount: number): number {
5606
+ if (charCount <= 6) return 1;
5607
+ return (
5608
+ 1 +
5609
+ AmLyrics.clamp((charCount - 6) / 10, 0, 1) * LONG_WORD_WIPE_EXTRA_RATIO
5610
+ );
5611
+ }
5612
+
5613
+ private static applyWipeShape(element: HTMLElement, charCount: number): void {
5614
+ const extra =
5615
+ AmLyrics.clamp((charCount - 6) / 10, 0, 1) * LONG_WORD_WIPE_EXTRA_EM;
5616
+ const width = BASE_WIPE_GRADIENT_EM + extra;
5617
+ element.style.setProperty('--wipe-gradient-width', `${width.toFixed(3)}em`);
5618
+ element.style.setProperty(
5619
+ '--wipe-gradient-half',
5620
+ `${(width / 2).toFixed(3)}em`,
5621
+ );
5622
+ }
5623
+
5624
+ private static ensureWordWipeGeometry(
5625
+ charSpans: HTMLElement[],
5626
+ charCount: number,
5627
+ ): void {
5628
+ if (charSpans.length === 0) return;
5629
+
5630
+ const approxWidthCh = Math.max(1, charCount || charSpans.length);
5631
+ charSpans.forEach((span, index) => {
5632
+ if (!span.style.getPropertyValue('--word-wipe-width')) {
5633
+ span.style.setProperty('--word-wipe-width', `${approxWidthCh}ch`);
5634
+ }
5635
+
5636
+ if (!span.style.getPropertyValue('--char-wipe-position')) {
5637
+ const startPct = Number.parseFloat(
5638
+ span.dataset.wipeStart || `${index / Math.max(1, charSpans.length)}`,
5639
+ );
5640
+ span.style.setProperty(
5641
+ '--char-wipe-position',
5642
+ `${-(AmLyrics.clamp(startPct, 0, 1) * approxWidthCh)}ch`,
5643
+ );
5644
+ }
5645
+ });
5646
+ }
5647
+
5648
+ private static clearPreHighlight(syllable: HTMLElement): void {
5649
+ const target = syllable;
5650
+ target.classList.remove('pre-highlight');
5651
+ target.style.removeProperty('--pre-wipe-duration');
5652
+ target.style.removeProperty('--pre-wipe-delay');
5653
+ target.style.animation = '';
5654
+ target
5655
+ .querySelectorAll('.pre-wipe-lead')
5656
+ .forEach(element => AmLyrics.clearPreWipeLead(element as HTMLElement));
5657
+ }
5658
+
5659
+ private static clearPreWipeLead(element: HTMLElement): void {
5660
+ element.classList.remove('pre-wipe-lead');
5661
+ element.style.removeProperty('--pre-wipe-duration');
5662
+ element.style.removeProperty('--pre-wipe-delay');
5663
+ }
5664
+
5665
+ private static hasTextBoundaryAfter(syllable: HTMLElement): boolean {
5666
+ return /\s$/.test(syllable.textContent || '');
5667
+ }
5668
+
5669
+ private static getSyllableWordIndex(syllable: HTMLElement): string {
5670
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
5671
+ const virtualWordId = wordElement?.dataset.virtualWordId;
5672
+ if (virtualWordId) {
5673
+ return `virtual:${virtualWordId}`;
5674
+ }
5675
+
5676
+ const virtualWordStart = wordElement?.dataset.virtualWordStart;
5677
+ const virtualWordEnd = wordElement?.dataset.virtualWordEnd;
5678
+ if (virtualWordStart || virtualWordEnd) {
5679
+ return `virtual:${virtualWordStart || ''}:${virtualWordEnd || ''}`;
5680
+ }
5681
+
5682
+ return (
5683
+ syllable.getAttribute('data-word-index') ||
5684
+ syllable.getAttribute('data-syllable-index') ||
5685
+ ''
5686
+ );
5687
+ }
5688
+
5689
+ private static getNextWordSyllable(
5690
+ syllables: HTMLElement[],
5691
+ index: number,
5692
+ ): HTMLElement | null {
5693
+ const current = syllables[index];
5694
+ const currentWordIndex = AmLyrics.getSyllableWordIndex(current);
5695
+ const previousSyllable = current;
5696
+
5697
+ for (let i = index + 1; i < syllables.length; i += 1) {
5698
+ const candidate = syllables[i];
5699
+ if (candidate.classList.contains('transliteration')) {
5700
+ // eslint-disable-next-line no-continue
5701
+ continue;
5702
+ }
5703
+
5704
+ const candidateWordIndex = AmLyrics.getSyllableWordIndex(candidate);
5705
+ if (
5706
+ candidateWordIndex === currentWordIndex ||
5707
+ !AmLyrics.hasTextBoundaryAfter(previousSyllable)
5708
+ ) {
5709
+ return null;
5710
+ }
5711
+
5712
+ return candidate;
5713
+ }
5714
+
5715
+ return null;
5716
+ }
5717
+
5718
+ private static getPreviousNonTransliterationSyllable(
5719
+ syllables: HTMLElement[],
5720
+ index: number,
5721
+ ): HTMLElement | null {
5722
+ for (let i = index - 1; i >= 0; i -= 1) {
5723
+ const candidate = syllables[i];
5724
+ if (!candidate.classList.contains('transliteration')) {
5725
+ return candidate;
5726
+ }
5727
+ }
5728
+
5729
+ return null;
5730
+ }
5731
+
5732
+ private static getRenderedWordSyllables(
5733
+ syllable: HTMLElement,
5734
+ ): HTMLElement[] {
5735
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
5736
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
5737
+ const wordSyllables = wordElements.flatMap(
5738
+ element =>
5739
+ Array.from(
5740
+ element.querySelectorAll('.lyrics-syllable'),
5741
+ ) as HTMLElement[],
5742
+ );
5743
+
5744
+ return wordSyllables.filter(
5745
+ wordSyllable => !wordSyllable.classList.contains('transliteration'),
5746
+ );
5747
+ }
5748
+
5749
+ private static getWordElementForSyllable(
5750
+ syllable: HTMLElement,
5751
+ ): HTMLElement | undefined {
5752
+ return syllable.parentElement?.parentElement as HTMLElement | undefined;
5753
+ }
5754
+
5755
+ private static getWordPreWipeKey(syllable: HTMLElement): string {
5756
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
5757
+ return (
5758
+ wordElement?.dataset.virtualWordId ||
5759
+ `${syllable.getAttribute('data-start-time') || ''}:${AmLyrics.getSyllableWordIndex(
5760
+ syllable,
5761
+ )}`
5762
+ );
5763
+ }
5764
+
5765
+ private static isPreWipeArmed(syllable: HTMLElement): boolean {
5766
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
5767
+ const target = wordElement as any;
5768
+ return Boolean(
5769
+ target?._wordPreWipeKey === AmLyrics.getWordPreWipeKey(syllable),
5770
+ );
5771
+ }
5772
+
5773
+ private static applyWordPreWipe(
5774
+ nextSyllable: HTMLElement,
5775
+ wordSyllables: HTMLElement[],
5776
+ currentTimeMs: number,
5777
+ preWipeStartMs: number,
5778
+ preWipeDurationMs: number,
5779
+ ): void {
5780
+ if (AmLyrics.isPreWipeArmed(nextSyllable)) return;
5781
+
5782
+ const wordElement = AmLyrics.getWordElementForSyllable(nextSyllable);
5783
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
5784
+ const charSpans = AmLyrics.getCachedVirtualWordCharSpans(wordElement, []);
5785
+ const elapsedPreWipe = currentTimeMs - preWipeStartMs;
5786
+ const charCount =
5787
+ charSpans.length ||
5788
+ wordSyllables.reduce(
5789
+ (total, wordSyllable) =>
5790
+ total + AmLyrics.getVisibleCharacterCount(wordSyllable),
5791
+ 0,
5792
+ ) ||
5793
+ AmLyrics.getVisibleCharacterCount(nextSyllable);
5794
+ AmLyrics.ensureWordWipeGeometry(charSpans, charCount);
5795
+ const leadChar = charSpans[0];
5796
+ const leadCharSyllable = leadChar?.closest(
5797
+ '.lyrics-syllable',
5798
+ ) as HTMLElement | null;
5799
+ const preWipeSyllable =
5800
+ leadCharSyllable || wordSyllables[0] || nextSyllable;
5801
+
5802
+ AmLyrics.applyWipeShape(preWipeSyllable, charCount);
5803
+ preWipeSyllable.style.setProperty(
5804
+ '--pre-wipe-duration',
5805
+ `${preWipeDurationMs}ms`,
5806
+ );
5807
+ preWipeSyllable.style.setProperty(
5808
+ '--pre-wipe-delay',
5809
+ `${-elapsedPreWipe}ms`,
5810
+ );
5811
+ preWipeSyllable.classList.add('pre-highlight');
5812
+
5813
+ let leadSpans = charSpans;
5814
+ if (leadSpans.length === 0 && leadChar) {
5815
+ leadSpans = [leadChar];
5816
+ }
5817
+ leadSpans.forEach(span => {
5818
+ AmLyrics.applyWipeShape(span, charCount);
5819
+ span.style.setProperty('--pre-wipe-duration', `${preWipeDurationMs}ms`);
5820
+ span.style.setProperty('--pre-wipe-delay', `${-elapsedPreWipe}ms`);
5821
+ span.classList.add('pre-wipe-lead');
5822
+ });
5823
+
5824
+ wordElements.forEach(element => {
5825
+ const target = element as any;
5826
+ target._wordPreWipeKey = AmLyrics.getWordPreWipeKey(nextSyllable);
5827
+ });
5828
+ }
5829
+
5830
+ private static maybePreWipeNextWord(
5831
+ syllables: HTMLElement[],
5832
+ index: number,
5833
+ currentTimeMs: number,
5834
+ currentEndTimeMs: number,
5835
+ ): void {
5836
+ const syllable = syllables[index];
5837
+ if (
5838
+ syllable.classList.contains('line-synced') ||
5839
+ syllable.classList.contains('transliteration') ||
5840
+ syllable.closest('.lyrics-gap')
5841
+ ) {
5842
+ return;
5843
+ }
5844
+
5845
+ const currentWordReady =
5846
+ syllable.classList.contains('finished') ||
5847
+ currentTimeMs >= currentEndTimeMs - WORD_PRE_WIPE_HANDOFF_LEAD_MS;
5848
+ if (!currentWordReady) {
5849
+ return;
5850
+ }
5851
+
5852
+ const nextSyllable = AmLyrics.getNextWordSyllable(syllables, index);
5853
+ if (
5854
+ !nextSyllable ||
5855
+ nextSyllable.classList.contains('line-synced') ||
5856
+ nextSyllable.classList.contains('transliteration') ||
5857
+ nextSyllable.closest('.lyrics-gap') ||
5858
+ nextSyllable.classList.contains('highlight') ||
5859
+ nextSyllable.classList.contains('finished')
5860
+ ) {
5861
+ return;
5862
+ }
5863
+
5864
+ const nextStartTimeMs = (nextSyllable as any)._cachedStartTime;
5865
+ if (!Number.isFinite(nextStartTimeMs)) return;
5866
+
5867
+ const gapMs = nextStartTimeMs - currentEndTimeMs;
5868
+ if (gapMs > NEXT_WORD_PRE_WIPE_MAX_GAP_MS || gapMs < -50) {
5869
+ return;
5870
+ }
5871
+
5872
+ const nextWordSyllables = AmLyrics.getRenderedWordSyllables(nextSyllable);
5873
+ const preWipeSyllables =
5874
+ nextWordSyllables.length > 0 ? nextWordSyllables : [nextSyllable];
5875
+ const wordElement = AmLyrics.getWordElementForSyllable(nextSyllable);
5876
+ const wordCharSpans = AmLyrics.getCachedVirtualWordCharSpans(
5877
+ wordElement,
5878
+ [],
5879
+ );
5880
+ const charCount =
5881
+ wordCharSpans.length ||
5882
+ preWipeSyllables.reduce(
5883
+ (total, wordSyllable) =>
5884
+ total + AmLyrics.getVisibleCharacterCount(wordSyllable),
5885
+ 0,
5886
+ );
5887
+ if (charCount <= 0) return;
5888
+
5889
+ const preWipeDuration = AmLyrics.clamp(
5890
+ 64 + charCount * 9,
5891
+ NEXT_WORD_PRE_WIPE_MIN_DURATION_MS,
5892
+ NEXT_WORD_PRE_WIPE_MAX_DURATION_MS,
5893
+ );
5894
+ const preWipeStart = Math.max(
5895
+ nextStartTimeMs - preWipeDuration,
5896
+ currentEndTimeMs - WORD_PRE_WIPE_HANDOFF_LEAD_MS,
5897
+ );
5898
+
5899
+ if (currentTimeMs < preWipeStart || currentTimeMs >= nextStartTimeMs) {
5900
+ return;
5901
+ }
5902
+
5903
+ AmLyrics.applyWordPreWipe(
5904
+ nextSyllable,
5905
+ preWipeSyllables,
5906
+ currentTimeMs,
5907
+ preWipeStart,
5908
+ preWipeDuration,
5909
+ );
5910
+ }
5911
+
5912
+ private static getCachedCharSpans(element: HTMLElement): HTMLElement[] {
5913
+ const cacheTarget = element as any;
5914
+ if (!cacheTarget._cachedCharSpans) {
5915
+ cacheTarget._cachedCharSpans = Array.from(
5916
+ element.querySelectorAll('span.char'),
5917
+ ) as HTMLElement[];
5918
+ }
5919
+ return cacheTarget._cachedCharSpans as HTMLElement[];
5920
+ }
5921
+
5922
+ private static getCachedVirtualWordElements(
5923
+ wordElement: HTMLElement | undefined,
5924
+ ): HTMLElement[] {
5925
+ if (!wordElement) return [];
5926
+
5927
+ const cacheTarget = wordElement as any;
5928
+ if (cacheTarget._cachedVirtualWordElements) {
5929
+ return cacheTarget._cachedVirtualWordElements as HTMLElement[];
5930
+ }
5931
+
5932
+ const { virtualWordId } = wordElement.dataset;
5933
+ let wordElements: HTMLElement[] = [wordElement];
5934
+ if (virtualWordId && wordElement.parentElement) {
5935
+ wordElements = Array.from(
5936
+ wordElement.parentElement.querySelectorAll('.lyrics-word'),
5937
+ ).filter(
5938
+ el => (el as HTMLElement).dataset.virtualWordId === virtualWordId,
5939
+ ) as HTMLElement[];
5940
+ }
5941
+
5942
+ wordElements.forEach(element => {
5943
+ const target = element as any;
5944
+ target._cachedVirtualWordElements = wordElements;
5945
+ });
5946
+
5947
+ return wordElements;
5948
+ }
5949
+
5950
+ private static getCachedVirtualWordCharSpans(
5951
+ wordElement: HTMLElement | undefined,
5952
+ fallbackCharSpans: HTMLElement[],
5953
+ ): HTMLElement[] {
5954
+ if (!wordElement) return fallbackCharSpans;
5955
+
5956
+ const cacheTarget = wordElement as any;
5957
+ if (cacheTarget._cachedVirtualWordCharSpans) {
5958
+ return cacheTarget._cachedVirtualWordCharSpans as HTMLElement[];
5959
+ }
5960
+
5961
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
5962
+ const charSpans = wordElements.flatMap(
5963
+ word => Array.from(word.querySelectorAll('span.char')) as HTMLElement[],
5964
+ );
5965
+ const result = charSpans.length > 0 ? charSpans : fallbackCharSpans;
5966
+
5967
+ wordElements.forEach(element => {
5968
+ const target = element as any;
5969
+ target._cachedVirtualWordCharSpans = result;
5970
+ });
5971
+
5972
+ return result;
5973
+ }
5974
+
5324
5975
  private static updateSyllableAnimation(
5325
5976
  syllable: HTMLElement,
5326
5977
  elapsedTimeMs = 0,
@@ -5328,18 +5979,32 @@ export class AmLyrics extends LitElement {
5328
5979
  if (syllable.classList.contains('highlight')) return;
5329
5980
 
5330
5981
  const { classList } = syllable;
5982
+ const hadPreHighlight = classList.contains('pre-highlight');
5331
5983
  const isRTL = classList.contains('rtl-text');
5332
- const charSpans = Array.from(
5333
- syllable.querySelectorAll('span.char'),
5334
- ) as HTMLElement[];
5984
+ const charSpans = AmLyrics.getCachedCharSpans(syllable);
5335
5985
  const wordElement = syllable.parentElement?.parentElement; // syllable-wrap -> word
5336
- const allWordCharSpans = wordElement
5337
- ? (Array.from(wordElement.querySelectorAll('span.char')) as HTMLElement[])
5338
- : [];
5339
- const isGrowable = wordElement?.classList.contains('growable');
5340
- const isCharRise = wordElement?.classList.contains('char-rise');
5986
+ const typedWordElement = wordElement as HTMLElement | undefined;
5987
+ const allWordElements =
5988
+ AmLyrics.getCachedVirtualWordElements(typedWordElement);
5989
+ const allWordCharSpans = AmLyrics.getCachedVirtualWordCharSpans(
5990
+ typedWordElement,
5991
+ charSpans,
5992
+ );
5993
+ const isGrowable = typedWordElement?.classList.contains('growable');
5994
+ const isCharRise = typedWordElement?.classList.contains('char-rise');
5995
+ const isCharDrag = typedWordElement?.classList.contains('char-drag');
5341
5996
  const isFirstSyllable =
5342
5997
  syllable.getAttribute('data-syllable-index') === '0';
5998
+ const syllableStartMs = parseFloat(
5999
+ syllable.getAttribute('data-start-time') || '0',
6000
+ );
6001
+ const virtualWordStartMs = parseFloat(
6002
+ typedWordElement?.dataset.virtualWordStart || '',
6003
+ );
6004
+ const isFirstInVirtualWord =
6005
+ isFirstSyllable &&
6006
+ (!Number.isFinite(virtualWordStartMs) ||
6007
+ Math.abs(syllableStartMs - virtualWordStartMs) < 0.5);
5343
6008
  const isFirstInContainer = isFirstSyllable; // Simplified
5344
6009
  const isGap = syllable.closest('.lyrics-gap') !== null;
5345
6010
 
@@ -5352,8 +6017,11 @@ export class AmLyrics extends LitElement {
5352
6017
  syllable.getAttribute('data-duration') ||
5353
6018
  '0',
5354
6019
  ) || syllableDurationMs;
6020
+ const wordElapsedTimeMs = Number.isFinite(virtualWordStartMs)
6021
+ ? elapsedTimeMs + (syllableStartMs - virtualWordStartMs)
6022
+ : elapsedTimeMs;
6023
+ const charWipeDurationMs = Math.max(wordDurationMs, syllableDurationMs);
5355
6024
 
5356
- // Use a Map to collect animations like YouLyPlus
5357
6025
  const charAnimationsMap = new Map<HTMLElement, string>();
5358
6026
  const styleUpdates: Array<{
5359
6027
  element: HTMLElement;
@@ -5362,7 +6030,7 @@ export class AmLyrics extends LitElement {
5362
6030
  }> = [];
5363
6031
 
5364
6032
  // Step 1: Grow Pass
5365
- if (isGrowable && isFirstSyllable && allWordCharSpans.length > 0) {
6033
+ if (isGrowable && isFirstInVirtualWord && allWordCharSpans.length > 0) {
5366
6034
  const finalDuration = wordDurationMs;
5367
6035
  const baseDelayPerChar = finalDuration * 0.09;
5368
6036
  const growDurationMs = finalDuration * 1.5;
@@ -5406,7 +6074,7 @@ export class AmLyrics extends LitElement {
5406
6074
  });
5407
6075
  }
5408
6076
 
5409
- if (isCharRise && isFirstSyllable && allWordCharSpans.length > 0) {
6077
+ if (isCharRise && isFirstInVirtualWord && allWordCharSpans.length > 0) {
5410
6078
  const finalDuration = Math.max(wordDurationMs, syllableDurationMs);
5411
6079
  const baseDelayPerChar = finalDuration * 0.09;
5412
6080
  const riseDurationMs = finalDuration * 1.5;
@@ -5421,18 +6089,82 @@ export class AmLyrics extends LitElement {
5421
6089
  });
5422
6090
  }
5423
6091
 
6092
+ if (isCharDrag && isFirstInVirtualWord && allWordCharSpans.length > 0) {
6093
+ const finalDuration = Math.max(wordDurationMs, syllableDurationMs);
6094
+ const baseDelayPerChar = AmLyrics.clamp(finalDuration * 0.15, 64, 118);
6095
+ const dragDurationMs = AmLyrics.clamp(finalDuration * 0.82, 560, 900);
6096
+
6097
+ allWordCharSpans.forEach(span => {
6098
+ const charIndex = parseFloat(span.dataset.syllableCharIndex || '0');
6099
+ const dragDelay = baseDelayPerChar * charIndex;
6100
+
6101
+ charAnimationsMap.set(
6102
+ span,
6103
+ `drag-char ${dragDurationMs}ms ease ${dragDelay}ms forwards`,
6104
+ );
6105
+ });
6106
+ }
6107
+
5424
6108
  // Step 2: Wipe Pass
5425
6109
  if (charSpans.length > 0) {
5426
- charSpans.forEach((span, charIndex) => {
6110
+ const wipeCharCount =
6111
+ allWordCharSpans.length ||
6112
+ charSpans.length ||
6113
+ AmLyrics.getVisibleCharacterCount(syllable);
6114
+ const wipeScale = AmLyrics.getLongWordWipeScale(wipeCharCount);
6115
+ AmLyrics.applyWipeShape(syllable, wipeCharCount);
6116
+ AmLyrics.ensureWordWipeGeometry(allWordCharSpans, wipeCharCount);
6117
+ allWordCharSpans.forEach(span =>
6118
+ AmLyrics.applyWipeShape(span, wipeCharCount),
6119
+ );
6120
+
6121
+ const hasWordLevelWipe =
6122
+ !isFirstInVirtualWord &&
6123
+ (Boolean((typedWordElement as any)?._wordWipeStarted) ||
6124
+ allWordCharSpans.some(span => span.style.animation.includes('wipe')));
6125
+ let charSpansToAnimate = charSpans;
6126
+ if (isFirstInVirtualWord) {
6127
+ charSpansToAnimate = allWordCharSpans;
6128
+ } else if (hasWordLevelWipe) {
6129
+ charSpansToAnimate = [];
6130
+ }
6131
+
6132
+ if (charSpansToAnimate.length > 0 && allWordElements.length > 0) {
6133
+ allWordElements.forEach(element => {
6134
+ const target = element as any;
6135
+ target._wordWipeStarted = true;
6136
+ target._wordPreWipeKey = undefined;
6137
+ });
6138
+ }
6139
+
6140
+ charSpansToAnimate.forEach((span, charIndex) => {
5427
6141
  const startPct = parseFloat(span.dataset.wipeStart || '0');
5428
6142
  const durationPct = parseFloat(span.dataset.wipeDuration || '0');
6143
+ const globalCharIndex = parseFloat(
6144
+ span.dataset.syllableCharIndex || `${charIndex}`,
6145
+ );
5429
6146
 
5430
- const wipeDelay = syllableDurationMs * startPct - elapsedTimeMs;
5431
- const wipeDuration = syllableDurationMs * durationPct;
5432
-
5433
- const useStartAnimation = isFirstInContainer && charIndex === 0;
6147
+ const hadCharPreWipe =
6148
+ span.classList.contains('pre-wipe-lead') ||
6149
+ (hadPreHighlight && globalCharIndex === 0);
6150
+ const charStartMs = charWipeDurationMs * startPct;
6151
+ const remainingWordWipeMs = Math.max(
6152
+ 0,
6153
+ charWipeDurationMs - charStartMs,
6154
+ );
6155
+ let wipeDelay = charStartMs - wordElapsedTimeMs;
6156
+ let wipeDuration = Math.min(
6157
+ charWipeDurationMs * durationPct * wipeScale,
6158
+ remainingWordWipeMs,
6159
+ );
6160
+ const useStartAnimation =
6161
+ isFirstInContainer && globalCharIndex === 0 && !hadCharPreWipe;
5434
6162
  let charWipeAnimation = 'wipe';
5435
- if (useStartAnimation) {
6163
+ if (hadCharPreWipe) {
6164
+ charWipeAnimation = 'wipe-word-from-pre';
6165
+ wipeDelay = -wordElapsedTimeMs;
6166
+ wipeDuration = charWipeDurationMs;
6167
+ } else if (useStartAnimation) {
5436
6168
  charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
5437
6169
  } else {
5438
6170
  charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
@@ -5446,30 +6178,15 @@ export class AmLyrics extends LitElement {
5446
6178
  if (
5447
6179
  existingAnimation &&
5448
6180
  (existingAnimation.includes('grow-dynamic') ||
5449
- existingAnimation.includes('rise-char'))
6181
+ existingAnimation.includes('rise-char') ||
6182
+ existingAnimation.includes('drag-char'))
5450
6183
  ) {
5451
6184
  animationParts.push(existingAnimation.split(',')[0].trim());
5452
6185
  }
5453
- if (charIndex > 0) {
5454
- const arrivalTime =
5455
- (span.dataset.preWipeArrival
5456
- ? parseFloat(span.dataset.preWipeArrival)
5457
- : syllableDurationMs * startPct) - elapsedTimeMs;
5458
- const constantDuration = parseFloat(
5459
- span.dataset.preWipeDuration || '100',
5460
- );
5461
- const animDelay = arrivalTime - constantDuration;
5462
-
5463
- if (constantDuration > 0) {
5464
- animationParts.push(
5465
- `pre-wipe-char ${constantDuration}ms linear ${animDelay}ms forwards`,
5466
- );
5467
- }
5468
- }
5469
-
5470
6186
  if (wipeDuration > 0) {
6187
+ const wipeFillMode = hadCharPreWipe ? 'both' : 'forwards';
5471
6188
  animationParts.push(
5472
- `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`,
6189
+ `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms ${wipeFillMode}`,
5473
6190
  );
5474
6191
  }
5475
6192
 
@@ -5482,10 +6199,15 @@ export class AmLyrics extends LitElement {
5482
6199
  const wipeRatio = parseFloat(
5483
6200
  syllable.getAttribute('data-wipe-ratio') || '1',
5484
6201
  );
5485
- const visualDuration = syllableDurationMs * wipeRatio;
6202
+ const wipeCharCount = AmLyrics.getVisibleCharacterCount(syllable);
6203
+ const wipeScale = AmLyrics.getLongWordWipeScale(wipeCharCount);
6204
+ const visualDuration = syllableDurationMs * wipeRatio * wipeScale;
6205
+ AmLyrics.applyWipeShape(syllable, wipeCharCount);
5486
6206
 
5487
6207
  let wipeAnimation = 'wipe';
5488
- if (isFirstInContainer) {
6208
+ if (hadPreHighlight) {
6209
+ wipeAnimation = isRTL ? 'wipe-from-pre-rtl' : 'wipe-from-pre';
6210
+ } else if (isFirstInContainer) {
5489
6211
  wipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
5490
6212
  } else {
5491
6213
  wipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
@@ -5499,18 +6221,28 @@ export class AmLyrics extends LitElement {
5499
6221
  }
5500
6222
 
5501
6223
  // --- WRITE PHASE ---
6224
+ if (allWordElements.length > 0) {
6225
+ allWordElements.forEach(element => {
6226
+ const target = element as any;
6227
+ target._wordPreWipeKey = undefined;
6228
+ });
6229
+ }
6230
+
5502
6231
  classList.remove('pre-highlight');
5503
6232
  classList.add('highlight');
6233
+ allWordCharSpans.forEach(span => AmLyrics.clearPreWipeLead(span));
6234
+
6235
+ // Apply keyframe variables before assigning animation strings so the
6236
+ // first painted frame never uses fallback transform values.
6237
+ for (const update of styleUpdates) {
6238
+ update.element.style.setProperty(update.property, update.value);
6239
+ }
5504
6240
 
5505
6241
  for (const [span, animationString] of charAnimationsMap.entries()) {
5506
6242
  span.style.willChange = 'transform';
6243
+ span.style.removeProperty('background-color');
5507
6244
  span.style.animation = animationString;
5508
6245
  }
5509
-
5510
- // Apply style updates
5511
- for (const update of styleUpdates) {
5512
- update.element.style.setProperty(update.property, update.value);
5513
- }
5514
6246
  }
5515
6247
 
5516
6248
  /**
@@ -5535,6 +6267,7 @@ export class AmLyrics extends LitElement {
5535
6267
  el.style.animation = '';
5536
6268
  el.style.transition = 'none';
5537
6269
  el.style.backgroundColor = 'var(--lyplus-text-secondary)';
6270
+ AmLyrics.clearPreWipeLead(el);
5538
6271
  }
5539
6272
 
5540
6273
  // Immediately remove all state classes
@@ -5546,12 +6279,22 @@ export class AmLyrics extends LitElement {
5546
6279
  );
5547
6280
  }
5548
6281
 
6282
+ private static resetWordAnimationState(line: HTMLElement): void {
6283
+ const wordElements = line.querySelectorAll('.lyrics-word');
6284
+ wordElements.forEach(wordElement => {
6285
+ const target = wordElement as any;
6286
+ target._wordPreWipeKey = undefined;
6287
+ target._wordWipeStarted = false;
6288
+ });
6289
+ }
6290
+
5549
6291
  /**
5550
6292
  * Reset all syllables in a line — batches deferred cleanup into a single rAF
5551
6293
  */
5552
6294
  private static resetSyllables(line: HTMLElement): void {
5553
6295
  if (!line) return;
5554
6296
  line.classList.remove('persist-highlight');
6297
+ AmLyrics.resetWordAnimationState(line);
5555
6298
  // eslint-disable-next-line no-param-reassign
5556
6299
  (line as any)._cachedSyllableElements = null;
5557
6300
  const syllables = line.getElementsByClassName('lyrics-syllable');
@@ -5583,6 +6326,7 @@ export class AmLyrics extends LitElement {
5583
6326
  private static unfinishSyllables(line: HTMLElement): void {
5584
6327
  if (!line) return;
5585
6328
  line.classList.remove('persist-highlight');
6329
+ AmLyrics.resetWordAnimationState(line);
5586
6330
  const syllables = line.getElementsByClassName('lyrics-syllable');
5587
6331
  for (let i = 0; i < syllables.length; i += 1) {
5588
6332
  const s = syllables[i] as HTMLElement;
@@ -5600,6 +6344,7 @@ export class AmLyrics extends LitElement {
5600
6344
  el.style.removeProperty('background-color');
5601
6345
  el.style.removeProperty('transition');
5602
6346
  el.style.removeProperty('filter');
6347
+ AmLyrics.clearPreWipeLead(el);
5603
6348
  }
5604
6349
  }
5605
6350
  }
@@ -5650,22 +6395,33 @@ export class AmLyrics extends LitElement {
5650
6395
  syllable.style.animation = '';
5651
6396
  syllable.style.removeProperty('--pre-wipe-duration');
5652
6397
  syllable.style.removeProperty('--pre-wipe-delay');
6398
+ syllable.style.removeProperty('background-color');
6399
+ AmLyrics.applyWipeShape(
6400
+ syllable,
6401
+ AmLyrics.getVisibleCharacterCount(syllable),
6402
+ );
5653
6403
  const chars = syllable.querySelectorAll('span.char');
5654
6404
  for (let ci = 0; ci < chars.length; ci += 1) {
5655
6405
  const charEl = chars[ci] as HTMLElement;
5656
6406
  const currentAnim = charEl.style.animation || '';
5657
6407
  if (
5658
6408
  currentAnim.includes('grow-dynamic') ||
5659
- currentAnim.includes('rise-char')
6409
+ currentAnim.includes('rise-char') ||
6410
+ currentAnim.includes('drag-char')
5660
6411
  ) {
5661
6412
  const parts = currentAnim.split(',').map(p => p.trim());
5662
6413
  const transformAnim = parts.find(
5663
- p => p.includes('grow-dynamic') || p.includes('rise-char'),
6414
+ p =>
6415
+ p.includes('grow-dynamic') ||
6416
+ p.includes('rise-char') ||
6417
+ p.includes('drag-char'),
5664
6418
  );
5665
6419
  charEl.style.animation = transformAnim || '';
5666
6420
  } else {
5667
6421
  charEl.style.animation = '';
5668
6422
  }
6423
+ charEl.style.backgroundColor = 'var(--lyplus-text-primary)';
6424
+ AmLyrics.clearPreWipeLead(charEl);
5669
6425
  }
5670
6426
  }
5671
6427
  }
@@ -5720,14 +6476,20 @@ export class AmLyrics extends LitElement {
5720
6476
  if (!(currentTimeMs < startTime - 1000 && !hasActiveState)) {
5721
6477
  let preHighlightReset = false;
5722
6478
 
5723
- // Pre-highlight reset logic
5724
- if (hasPreHighlight && i > 0) {
5725
- const prevSyllable = syllables[i - 1];
5726
- if (!prevSyllable.classList.contains('highlight')) {
5727
- classList.remove('pre-highlight');
5728
- syllable.style.removeProperty('--pre-wipe-duration');
5729
- syllable.style.removeProperty('--pre-wipe-delay');
5730
- syllable.style.animation = '';
6479
+ // Before the syllable starts, pre-highlight only belongs beside a
6480
+ // previous active word. Once the syllable starts, updateSyllableAnimation
6481
+ // consumes the class so the actual wipe can continue from the pre-wipe
6482
+ // pose instead of restarting from the beginning.
6483
+ if (hasPreHighlight && currentTimeMs < startTime) {
6484
+ const prevSyllable = AmLyrics.getPreviousNonTransliterationSyllable(
6485
+ syllables,
6486
+ i,
6487
+ );
6488
+ const previousCarriesHighlight =
6489
+ prevSyllable?.classList.contains('highlight') ||
6490
+ prevSyllable?.classList.contains('finished');
6491
+ if (!previousCarriesHighlight) {
6492
+ AmLyrics.clearPreHighlight(syllable);
5731
6493
  preHighlightReset = true;
5732
6494
  }
5733
6495
  }
@@ -5760,6 +6522,8 @@ export class AmLyrics extends LitElement {
5760
6522
  // Not yet started
5761
6523
  AmLyrics.resetSyllable(syllable);
5762
6524
  }
6525
+
6526
+ AmLyrics.maybePreWipeNextWord(syllables, i, currentTimeMs, endTime);
5763
6527
  }
5764
6528
  }
5765
6529
  }
@@ -6120,6 +6884,9 @@ export class AmLyrics extends LitElement {
6120
6884
  data-end-time="${endTimeMs}"
6121
6885
  data-duration="${durationMs}"
6122
6886
  data-syllable-index="${syllableIndex}"
6887
+ data-word-index="${syllableIndex}"
6888
+ data-word-length="${syllable.text.replace(/\s/g, '')
6889
+ .length}"
6123
6890
  data-wipe-ratio="1"
6124
6891
  >${syllable.text}</span
6125
6892
  >${bgRomanizedText}</span
@@ -6144,12 +6911,14 @@ export class AmLyrics extends LitElement {
6144
6911
  const groupGrowable = lineData?.groupGrowable ?? [];
6145
6912
  const groupGlowing = lineData?.groupGlowing ?? [];
6146
6913
  const groupCharRise = lineData?.groupCharRise ?? [];
6914
+ const groupCharDrag = lineData?.groupCharDrag ?? [];
6147
6915
  const vwFullText = lineData?.vwFullText ?? [];
6148
6916
  const vwFullDuration = lineData?.vwFullDuration ?? [];
6149
6917
  const vwCharOffset = lineData?.vwCharOffset ?? [];
6918
+ const vwStartMs = lineData?.vwStartMs ?? [];
6919
+ const vwEndMs = lineData?.vwEndMs ?? [];
6150
6920
  const lineIsRTL = lineData?.lineIsRTL ?? false;
6151
6921
 
6152
- // Create main vocals using YouLyPlus syllable structure
6153
6922
  const mainVocalElement = html`<p
6154
6923
  class="main-vocal-container ${lineIsRTL ? 'rtl-text' : ''}"
6155
6924
  >
@@ -6157,21 +6926,26 @@ export class AmLyrics extends LitElement {
6157
6926
  const isGrowable = groupGrowable[groupIdx];
6158
6927
  const isGlowing = groupGlowing[groupIdx];
6159
6928
  const isCharRise = groupCharRise[groupIdx];
6160
- const isAnimatedByChar = isGrowable || isCharRise;
6929
+ const isCharDrag = groupCharDrag[groupIdx];
6930
+ const isAnimatedByChar = isGrowable || isCharRise || isCharDrag;
6161
6931
  const groupLineSynced = group.some(s => s.lineSynced);
6162
6932
 
6163
6933
  const wordText = isAnimatedByChar ? vwFullText[groupIdx] : '';
6164
6934
  const wordDuration = isAnimatedByChar
6165
6935
  ? vwFullDuration[groupIdx]
6166
6936
  : 0;
6167
- const wordNumChars = wordText.length;
6937
+ const wordNumChars = wordText.replace(/\s/g, '').length;
6168
6938
  const groupCharOffset = isAnimatedByChar
6169
6939
  ? vwCharOffset[groupIdx]
6170
6940
  : 0;
6941
+ const virtualWordId = `${lineIndex}:${vwStartMs[groupIdx]}:${vwEndMs[groupIdx]}`;
6942
+ const virtualWordStart = vwStartMs[groupIdx];
6943
+ const virtualWordEnd = vwEndMs[groupIdx];
6171
6944
 
6172
6945
  let sylCharAccumulator = 0;
6173
6946
 
6174
6947
  const groupText = group.map(s => s.text).join('');
6948
+ const visibleWordLength = groupText.replace(/\s/g, '').length;
6175
6949
  const shouldAllowBreak =
6176
6950
  groupText.trim().length >= 16 ||
6177
6951
  /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(
@@ -6191,9 +6965,12 @@ export class AmLyrics extends LitElement {
6191
6965
  return html`<span
6192
6966
  class="lyrics-word${isGrowable ? ' growable' : ''}${isCharRise
6193
6967
  ? ' char-rise'
6194
- : ''}${isGlowing ? ' glowing' : ''}${shouldAllowBreak
6195
- ? ' allow-break'
6196
- : ''}"
6968
+ : ''}${isCharDrag ? ' char-drag' : ''}${isGlowing
6969
+ ? ' glowing'
6970
+ : ''}${shouldAllowBreak ? ' allow-break' : ''}"
6971
+ data-virtual-word-id="${virtualWordId}"
6972
+ data-virtual-word-start="${virtualWordStart}"
6973
+ data-virtual-word-end="${virtualWordEnd}"
6197
6974
  style="--rise-duration: ${riseDuration}s"
6198
6975
  >${group.map((syllable, sylIdx) => {
6199
6976
  const startTimeMs = syllable.timestamp;
@@ -6221,17 +6998,39 @@ export class AmLyrics extends LitElement {
6221
6998
  let syllableContent: any = text;
6222
6999
 
6223
7000
  if (isAnimatedByChar) {
6224
- let charIndexInsideSyllable = 0;
6225
7001
  const numCharsInSyllable =
6226
7002
  text.replace(/\s/g, '').length || 1;
7003
+ const hasVirtualTiming =
7004
+ wordDuration > 0 && Number.isFinite(virtualWordStart);
7005
+ const syllableStartRatio = hasVirtualTiming
7006
+ ? AmLyrics.clamp(
7007
+ (startTimeMs - virtualWordStart) / wordDuration,
7008
+ 0,
7009
+ 1,
7010
+ )
7011
+ : 0;
7012
+ const syllableDurationRatio = hasVirtualTiming
7013
+ ? AmLyrics.clamp(durationMs / wordDuration, 0, 1)
7014
+ : 1;
7015
+ let charIndexInsideSyllable = 0;
6227
7016
 
6228
7017
  syllableContent = html`${text.split('').map(char => {
6229
7018
  if (char === ' ') return ' ';
6230
7019
 
6231
7020
  const charIndexInsideWord =
6232
7021
  groupCharOffset + sylCharAccumulator;
6233
- const charStartPercentVal =
6234
- charIndexInsideSyllable / numCharsInSyllable;
7022
+ const localCharIndex = charIndexInsideSyllable;
7023
+ const visibleWordChars = Math.max(1, wordNumChars);
7024
+ const charStartPercentVal = AmLyrics.clamp(
7025
+ syllableStartRatio +
7026
+ (localCharIndex / numCharsInSyllable) *
7027
+ syllableDurationRatio,
7028
+ 0,
7029
+ 1,
7030
+ );
7031
+ const charDurationPercentVal =
7032
+ syllableDurationRatio / numCharsInSyllable ||
7033
+ 1 / visibleWordChars;
6235
7034
 
6236
7035
  sylCharAccumulator += 1;
6237
7036
  charIndexInsideSyllable += 1;
@@ -6293,29 +7092,41 @@ export class AmLyrics extends LitElement {
6293
7092
  1,
6294
7093
  Math.max(0.3, effectiveDuration / 2000),
6295
7094
  );
6296
- const charTranslateYPeak =
7095
+ const baseTranslateYPeak =
6297
7096
  -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
6298
7097
 
6299
7098
  const position = (charIndexInsideWord + 0.5) / wordNumChars;
6300
7099
  const horizontalOffset =
6301
7100
  (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25);
7101
+ const isDragMotion = isCharDrag;
7102
+ let charTranslateYPeak = baseTranslateYPeak;
7103
+ if (isCharRise) {
7104
+ charTranslateYPeak = 0;
7105
+ } else if (isDragMotion) {
7106
+ charTranslateYPeak = -0.78;
7107
+ }
6302
7108
 
7109
+ let motionHorizontalOffset = horizontalOffset;
7110
+ if (isCharRise) {
7111
+ motionHorizontalOffset = 0;
7112
+ } else if (isDragMotion) {
7113
+ motionHorizontalOffset = 0;
7114
+ }
6303
7115
  return html`<span
6304
7116
  class="char"
6305
7117
  data-char-index="${charIndexInsideWord}"
6306
7118
  data-syllable-char-index="${charIndexInsideWord}"
6307
7119
  data-wipe-start="${charStartPercentVal.toFixed(4)}"
6308
- data-wipe-duration="${(1 / numCharsInSyllable).toFixed(
6309
- 4,
6310
- )}"
7120
+ data-wipe-duration="${charDurationPercentVal.toFixed(4)}"
6311
7121
  data-horizontal-offset="${horizontalOffset.toFixed(2)}"
6312
7122
  data-max-scale="${charMaxScale.toFixed(3)}"
6313
7123
  data-matrix-scale="${(charMaxScale * 0.98).toFixed(3)}"
6314
- data-char-offset-x="${(horizontalOffset * 0.98).toFixed(
6315
- 2,
6316
- )}"
7124
+ data-char-offset-x="${(
7125
+ motionHorizontalOffset * 0.98
7126
+ ).toFixed(2)}"
6317
7127
  data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
6318
7128
  data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
7129
+ style="--word-wipe-width: ${visibleWordChars}ch; --char-wipe-position: -${charIndexInsideWord}ch"
6319
7130
  >${char}</span
6320
7131
  >`;
6321
7132
  })}`;
@@ -6334,6 +7145,8 @@ export class AmLyrics extends LitElement {
6334
7145
  data-duration="${durationMs}"
6335
7146
  data-word-duration="${wordDuration}"
6336
7147
  data-syllable-index="${sylIdx}"
7148
+ data-word-index="${groupIdx}"
7149
+ data-word-length="${visibleWordLength}"
6337
7150
  data-wipe-ratio="1"
6338
7151
  >${syllableContent}</span
6339
7152
  >${romanizedText}</span
@@ -6574,13 +7387,14 @@ export class AmLyrics extends LitElement {
6574
7387
  <button
6575
7388
  class="download-button source-switch-btn"
6576
7389
  title="Switch Lyrics Source"
6577
- style="font-family: inherit; font-size: 11px; padding: 2px 6px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.2); background: transparent; cursor: pointer; color: #aaa; display: inline-flex; align-items: center;"
6578
7390
  @click=${this.switchSource}
6579
7391
  ?disabled=${this.isFetchingAlternatives}
6580
7392
  >
6581
7393
  <svg
6582
- class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
6583
- style="margin-right: 4px;"
7394
+ class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up ${this
7395
+ .isFetchingAlternatives
7396
+ ? 'is-loading'
7397
+ : ''}"
6584
7398
  xmlns="http://www.w3.org/2000/svg"
6585
7399
  width="12"
6586
7400
  height="12"