@uimaxbai/am-lyrics 1.5.4 → 1.5.6

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.4';
5
+ const VERSION = '1.5.6';
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 = 100;
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;
@@ -292,7 +303,7 @@ export class AmLyrics extends LitElement {
292
303
  .lyrics-line.bg-expanded .background-vocal-container {
293
304
  max-height: 4em;
294
305
  opacity: 1;
295
- will-change: max-height, opacity;
306
+ will-change: opacity;
296
307
  }
297
308
 
298
309
  .lyrics-line.bg-expanded .background-vocal-wrap {
@@ -309,6 +320,18 @@ export class AmLyrics extends LitElement {
309
320
  opacity: 1;
310
321
  }
311
322
 
323
+ /* Predictive scrolling begins before the next timestamp. Start dimming
324
+ the outgoing line at the same moment so it settles with the scroll. */
325
+ .lyrics-line.scroll-exiting {
326
+ opacity: 0.8;
327
+ color: var(--lyplus-text-secondary);
328
+ transition:
329
+ opacity var(--scroll-duration, 400ms) cubic-bezier(0.41, 0, 0.12, 0.99),
330
+ transform var(--scroll-duration, 400ms)
331
+ cubic-bezier(0.41, 0, 0.12, 0.99) var(--lyrics-line-delay, 0ms),
332
+ filter var(--scroll-duration, 400ms) ease;
333
+ }
334
+
312
335
  .lyrics-line.persist-highlight {
313
336
  filter: none !important;
314
337
  opacity: 1;
@@ -444,11 +467,22 @@ export class AmLyrics extends LitElement {
444
467
  white-space: nowrap;
445
468
  }
446
469
 
470
+ .lyrics-word.char-drag {
471
+ display: inline-block;
472
+ vertical-align: baseline;
473
+ white-space: nowrap;
474
+ }
475
+
447
476
  .lyrics-word.char-rise.allow-break {
448
477
  display: inline;
449
478
  white-space: normal;
450
479
  }
451
480
 
481
+ .lyrics-word.char-drag.allow-break {
482
+ display: inline;
483
+ white-space: normal;
484
+ }
485
+
452
486
  .lyrics-syllable-wrap {
453
487
  display: inline;
454
488
  }
@@ -503,44 +537,30 @@ export class AmLyrics extends LitElement {
503
537
  .lyrics-line.active:not(.lyrics-gap)
504
538
  .lyrics-syllable.pre-highlight.no-chars {
505
539
  background-repeat: no-repeat;
506
- background-image:
507
- linear-gradient(
508
- 90deg,
509
- #ffffff00 0%,
510
- var(--lyplus-text-primary, #fff) 50%,
511
- #0000 100%
512
- ),
513
- linear-gradient(
514
- 90deg,
515
- var(--lyplus-text-primary, #fff) 100%,
516
- #0000 100%
517
- );
518
- background-size:
519
- 0.5em 100%,
520
- 0% 100%;
521
- background-position:
522
- -0.5em 0%,
523
- -0.25em 0%;
540
+ background-image: linear-gradient(
541
+ 90deg,
542
+ var(--lyplus-text-primary, #fff) 0%,
543
+ var(--lyplus-text-primary, #fff)
544
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
545
+ #0000 100%
546
+ );
547
+ background-size: 0% 100%;
548
+ background-position: left;
524
549
  }
525
550
 
526
551
  .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.highlight.rtl-text,
527
552
  .lyrics-line.active:not(.lyrics-gap)
528
553
  .lyrics-syllable.pre-highlight.rtl-text {
529
554
  direction: rtl;
530
- background-image:
531
- linear-gradient(
532
- -90deg,
533
- var(--lyplus-text-primary) 0%,
534
- transparent 100%
535
- ),
536
- linear-gradient(
537
- -90deg,
538
- var(--lyplus-text-primary) 100%,
539
- transparent 100%
540
- );
541
- background-position:
542
- calc(100% + 0.5em) 0%,
543
- right 0%;
555
+ background-image: linear-gradient(
556
+ -90deg,
557
+ var(--lyplus-text-primary) 0%,
558
+ var(--lyplus-text-primary)
559
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
560
+ transparent 100%
561
+ );
562
+ background-size: 0% 100%;
563
+ background-position: right 0%;
544
564
  }
545
565
 
546
566
  /* Background vocals: muted gray wipe instead of white.
@@ -557,18 +577,13 @@ export class AmLyrics extends LitElement {
557
577
  .lyrics-line.pre-active
558
578
  .background-vocal-container
559
579
  .lyrics-syllable.pre-highlight.no-chars {
560
- background-image:
561
- linear-gradient(
562
- 90deg,
563
- #ffffff00 0%,
564
- color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 50%,
565
- #0000 100%
566
- ),
567
- linear-gradient(
568
- 90deg,
569
- color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 100%,
570
- #0000 100%
571
- );
580
+ background-image: linear-gradient(
581
+ 90deg,
582
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 0%,
583
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888)
584
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
585
+ #0000 100%
586
+ );
572
587
  }
573
588
 
574
589
  .lyrics-line.active
@@ -583,17 +598,13 @@ export class AmLyrics extends LitElement {
583
598
  .lyrics-line.pre-active
584
599
  .background-vocal-container
585
600
  .lyrics-syllable.pre-highlight.rtl-text {
586
- background-image:
587
- linear-gradient(
588
- -90deg,
589
- color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 0%,
590
- transparent 100%
591
- ),
592
- linear-gradient(
593
- -90deg,
594
- color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 100%,
595
- transparent 100%
596
- );
601
+ background-image: linear-gradient(
602
+ -90deg,
603
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 0%,
604
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888)
605
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
606
+ transparent 100%
607
+ );
597
608
  }
598
609
 
599
610
  /* Non-growable words float up with a gentle curve */
@@ -617,6 +628,10 @@ export class AmLyrics extends LitElement {
617
628
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
618
629
  }
619
630
 
631
+ .lyrics-word.char-drag .lyrics-syllable.cleanup .char {
632
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
633
+ }
634
+
620
635
  .lyrics-line.persist-highlight
621
636
  .lyrics-word.growable
622
637
  .lyrics-syllable.finished
@@ -624,6 +639,10 @@ export class AmLyrics extends LitElement {
624
639
  .lyrics-line.persist-highlight
625
640
  .lyrics-word.char-rise
626
641
  .lyrics-syllable.finished
642
+ .char,
643
+ .lyrics-line.persist-highlight
644
+ .lyrics-word.char-drag
645
+ .lyrics-syllable.finished
627
646
  .char {
628
647
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
629
648
  }
@@ -734,6 +753,10 @@ export class AmLyrics extends LitElement {
734
753
  transform 0.7s ease;
735
754
  }
736
755
 
756
+ .lyrics-word.char-drag span.char {
757
+ transition: color 0.18s;
758
+ }
759
+
737
760
  /* Active char spans: structural only, wipe animation sets gradient */
738
761
  .lyrics-line.active .lyrics-syllable span.char {
739
762
  background-clip: text;
@@ -752,52 +775,34 @@ export class AmLyrics extends LitElement {
752
775
  #0000 100%
753
776
  );
754
777
  background-size:
755
- 0.5em 100%,
778
+ var(--wipe-gradient-width, 0.75em) 100%,
756
779
  0% 100%;
757
780
  background-position:
758
- -0.5em 0%,
759
- -0.25em 0%;
781
+ calc(-1 * var(--wipe-gradient-width, 0.75em)) 0%,
782
+ left;
760
783
  transition:
761
784
  transform 0.7s ease,
762
785
  color 0.18s;
763
786
  }
764
787
 
765
788
  .lyrics-line.active .lyrics-syllable span.char.highlight {
766
- background-image:
767
- linear-gradient(
768
- -90deg,
769
- var(--lyplus-text-primary, #fff) 0%,
770
- #0000 100%
771
- ),
772
- linear-gradient(
773
- -90deg,
774
- var(--lyplus-text-primary, #fff) 100%,
775
- #0000 100%
776
- );
777
- background-position:
778
- calc(100% + 0.5em) 0%,
779
- calc(100% + 0.25em) 0%;
789
+ background-image: linear-gradient(
790
+ -90deg,
791
+ var(--lyplus-text-primary, #fff) 0%,
792
+ var(--lyplus-text-primary, #fff)
793
+ calc(100% - var(--wipe-gradient-width, 0.75em)),
794
+ #0000 100%
795
+ );
796
+ background-size: 0% 100%;
797
+ background-position: right 0%;
780
798
  }
781
799
 
782
- .lyrics-line.active .lyrics-syllable.pre-highlight span.char {
783
- background-image:
784
- linear-gradient(
785
- 90deg,
786
- #ffffff00 0%,
787
- var(--lyplus-text-primary, #fff) 50%,
788
- #0000 100%
789
- ),
790
- linear-gradient(
791
- 90deg,
792
- var(--lyplus-text-primary, #fff) 100%,
793
- #0000 100%
794
- );
795
- background-size:
796
- 0.75em 100%,
797
- 0% 100%;
798
- background-position:
799
- -0.85em 0%,
800
- -0.25em 0%;
800
+ .lyrics-line.active .lyrics-syllable span.char.pre-wipe-lead {
801
+ animation-name: char-pre-wipe;
802
+ animation-duration: var(--pre-wipe-duration);
803
+ animation-delay: var(--pre-wipe-delay);
804
+ animation-timing-function: linear;
805
+ animation-fill-mode: forwards;
801
806
  }
802
807
 
803
808
  /* ==========================================================================
@@ -874,13 +879,7 @@ export class AmLyrics extends LitElement {
874
879
  color: var(--lyplus-text-primary) !important;
875
880
  }
876
881
 
877
- .lyrics-line.pre-active .lyrics-syllable.line-synced {
878
- animation: fade-in-line 0.14s ease-out forwards !important;
879
- color: var(--lyplus-text-primary) !important;
880
- }
881
-
882
- .lyrics-line.active .lyrics-syllable.line-synced span.char,
883
- .lyrics-line.pre-active .lyrics-syllable.line-synced span.char {
882
+ .lyrics-line.active .lyrics-syllable.line-synced span.char {
884
883
  background-image: none !important;
885
884
  background-color: var(--lyplus-text-primary) !important;
886
885
  transition: background-color 120ms ease-out !important;
@@ -1053,6 +1052,49 @@ export class AmLyrics extends LitElement {
1053
1052
  gap: 4px;
1054
1053
  }
1055
1054
 
1055
+ .source-switch-btn {
1056
+ position: relative;
1057
+ display: inline-flex;
1058
+ align-items: center;
1059
+ padding: 2px 8px;
1060
+ border: 1px solid rgba(255, 255, 255, 0.2);
1061
+ min-height: 28px;
1062
+ background: transparent;
1063
+ border-radius: 6px;
1064
+ color: #aaa;
1065
+ cursor: pointer;
1066
+ font-family: inherit;
1067
+ font-size: 11px;
1068
+ transition:
1069
+ color 0.2s ease,
1070
+ border-color 0.2s ease,
1071
+ background-color 0.2s ease,
1072
+ transform 0.12s ease;
1073
+ }
1074
+
1075
+ .source-switch-btn::before {
1076
+ content: '';
1077
+ position: absolute;
1078
+ inset: -6px;
1079
+ }
1080
+
1081
+ .source-switch-btn:active:not(:disabled) {
1082
+ transform: scale(0.96);
1083
+ }
1084
+
1085
+ .source-switch-btn:disabled {
1086
+ cursor: default;
1087
+ opacity: 0.7;
1088
+ }
1089
+
1090
+ .source-switch-svg {
1091
+ margin-right: 4px;
1092
+ }
1093
+
1094
+ .source-switch-svg.is-loading {
1095
+ animation: source-switch-spin 1s linear infinite;
1096
+ }
1097
+
1056
1098
  .control-button {
1057
1099
  background: transparent;
1058
1100
  border: 1px solid rgba(255, 255, 255, 0.3);
@@ -1061,7 +1103,10 @@ export class AmLyrics extends LitElement {
1061
1103
  font-size: 0.8em;
1062
1104
  color: rgba(255, 255, 255, 0.6);
1063
1105
  cursor: pointer;
1064
- transition: all 0.2s;
1106
+ transition:
1107
+ color 0.2s,
1108
+ border-color 0.2s,
1109
+ background-color 0.2s;
1065
1110
  font-weight: normal;
1066
1111
  }
1067
1112
 
@@ -1199,136 +1244,157 @@ export class AmLyrics extends LitElement {
1199
1244
  KEYFRAME ANIMATIONS
1200
1245
  ========================================================================== */
1201
1246
 
1247
+ @keyframes source-switch-spin {
1248
+ to {
1249
+ transform: rotate(360deg);
1250
+ }
1251
+ }
1252
+
1202
1253
  /* Wipe animation for syllables */
1203
1254
  @keyframes wipe {
1204
1255
  from {
1205
- background-size:
1206
- 0.75em 100%,
1207
- 0% 100%;
1208
- background-position:
1209
- -0.375em 0%,
1210
- left;
1256
+ background-size: 0% 100%;
1257
+ background-position: left;
1211
1258
  }
1212
1259
  to {
1213
- background-size:
1214
- 0.75em 100%,
1215
- 100% 100%;
1216
- background-position:
1217
- calc(100% + 0.375em) 0%,
1218
- left;
1260
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1261
+ background-position: left;
1262
+ }
1263
+ }
1264
+
1265
+ @keyframes wipe-from-pre {
1266
+ from {
1267
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
1268
+ background-position: left;
1269
+ }
1270
+ to {
1271
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1272
+ background-position: left;
1219
1273
  }
1220
1274
  }
1221
1275
 
1222
1276
  @keyframes start-wipe {
1223
1277
  0% {
1224
- background-size:
1225
- 0.75em 100%,
1226
- 0% 100%;
1227
- background-position:
1228
- -0.75em 0%,
1229
- -0.375em 0%;
1278
+ background-size: 0% 100%;
1279
+ background-position: left;
1230
1280
  }
1231
1281
  100% {
1232
- background-size:
1233
- 0.75em 100%,
1234
- 100% 100%;
1235
- background-position:
1236
- calc(100% + 0.375em) 0%,
1237
- left;
1282
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1283
+ background-position: left;
1238
1284
  }
1239
1285
  }
1240
1286
 
1241
1287
  @keyframes wipe-rtl {
1242
1288
  from {
1243
- background-size:
1244
- 0.75em 100%,
1245
- 0% 100%;
1246
- background-position:
1247
- calc(100% + 0.375em) 0%,
1248
- calc(100% + 0.36em) 0%;
1289
+ background-size: 0% 100%;
1290
+ background-position: right 0%;
1249
1291
  }
1250
1292
  to {
1251
- background-size:
1252
- 0.75em 100%,
1253
- 100% 100%;
1254
- background-position:
1255
- -0.75em 0%,
1256
- right 0%;
1293
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1294
+ background-position: right 0%;
1295
+ }
1296
+ }
1297
+
1298
+ @keyframes wipe-from-pre-rtl {
1299
+ from {
1300
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
1301
+ background-position: right 0%;
1302
+ }
1303
+ to {
1304
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1305
+ background-position: right 0%;
1257
1306
  }
1258
1307
  }
1259
1308
 
1260
1309
  @keyframes start-wipe-rtl {
1261
1310
  0% {
1262
- background-size:
1263
- 0.75em 100%,
1264
- 0% 100%;
1265
- background-position:
1266
- calc(100% + 0.75em) 0%,
1267
- calc(100% + 0.5em) 0%;
1311
+ background-size: 0% 100%;
1312
+ background-position: right 0%;
1268
1313
  }
1269
1314
  100% {
1270
- background-size:
1271
- 0.75em 100%,
1272
- 100% 100%;
1273
- background-position:
1274
- -0.75em 0%,
1275
- right 0%;
1315
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1316
+ background-position: right 0%;
1276
1317
  }
1277
1318
  }
1278
1319
 
1279
1320
  @keyframes pre-wipe-universal {
1321
+ from {
1322
+ background-size: 0% 100%;
1323
+ background-position: left;
1324
+ }
1325
+ to {
1326
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
1327
+ background-position: left;
1328
+ }
1329
+ }
1330
+
1331
+ @keyframes pre-wipe-universal-rtl {
1332
+ from {
1333
+ background-size: 0% 100%;
1334
+ background-position: right 0%;
1335
+ }
1336
+ to {
1337
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
1338
+ background-position: right 0%;
1339
+ }
1340
+ }
1341
+
1342
+ /* Character-rendered words use a separate moving gradient in front of
1343
+ their solid fill. This makes the individual glyph wipes read as one
1344
+ continuous word-level wipe. */
1345
+ @keyframes char-pre-wipe {
1280
1346
  from {
1281
1347
  background-size:
1282
- 0.75em 100%,
1348
+ var(--wipe-gradient-width, 0.75em) 100%,
1283
1349
  0% 100%;
1284
1350
  background-position:
1285
- -0.75em 0%,
1351
+ calc(-1 * var(--wipe-gradient-width, 0.75em)) 0%,
1286
1352
  left;
1287
1353
  }
1288
1354
  to {
1289
1355
  background-size:
1290
- 0.75em 100%,
1356
+ var(--wipe-gradient-width, 0.75em) 100%,
1291
1357
  0% 100%;
1292
1358
  background-position:
1293
- -0.375em 0%,
1359
+ calc(-1 * var(--wipe-gradient-half, 0.375em)) 0%,
1294
1360
  left;
1295
1361
  }
1296
1362
  }
1297
1363
 
1298
- @keyframes pre-wipe-universal-rtl {
1364
+ @keyframes char-start-wipe {
1299
1365
  from {
1300
1366
  background-size:
1301
- 0.75em 100%,
1367
+ var(--wipe-gradient-width, 0.75em) 100%,
1302
1368
  0% 100%;
1303
1369
  background-position:
1304
- calc(100% + 0.75em) 0%,
1305
- right 0%;
1370
+ calc(-1 * var(--wipe-gradient-width, 0.75em)) 0%,
1371
+ left;
1306
1372
  }
1307
1373
  to {
1308
1374
  background-size:
1309
- 0.75em 100%,
1310
- 0% 100%;
1375
+ var(--wipe-gradient-width, 0.75em) 100%,
1376
+ 100% 100%;
1311
1377
  background-position:
1312
- calc(100% + 0.375em) 0%,
1313
- right 0%;
1378
+ calc(100% + var(--wipe-gradient-half, 0.375em)) 0%,
1379
+ left;
1314
1380
  }
1315
1381
  }
1316
1382
 
1317
- @keyframes pre-wipe-char {
1383
+ @keyframes char-wipe {
1318
1384
  from {
1319
1385
  background-size:
1320
- 0.75em 100%,
1386
+ var(--wipe-gradient-width, 0.75em) 100%,
1321
1387
  0% 100%;
1322
1388
  background-position:
1323
- -0.75em 0%,
1389
+ calc(-1 * var(--wipe-gradient-half, 0.375em)) 0%,
1324
1390
  left;
1325
1391
  }
1326
1392
  to {
1327
1393
  background-size:
1328
- 0.75em 100%,
1329
- 0% 100%;
1394
+ var(--wipe-gradient-width, 0.75em) 100%,
1395
+ 100% 100%;
1330
1396
  background-position:
1331
- -0.375em 0%,
1397
+ calc(100% + var(--wipe-gradient-half, 0.375em)) 0%,
1332
1398
  left;
1333
1399
  }
1334
1400
  }
@@ -1423,6 +1489,15 @@ export class AmLyrics extends LitElement {
1423
1489
  }
1424
1490
  }
1425
1491
 
1492
+ @keyframes drag-char {
1493
+ 0% {
1494
+ transform: translate3d(0, 0, 0);
1495
+ }
1496
+ 100% {
1497
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
1498
+ }
1499
+ }
1500
+
1426
1501
  @keyframes grow-static {
1427
1502
  0%,
1428
1503
  100% {
@@ -1668,14 +1743,20 @@ export class AmLyrics extends LitElement {
1668
1743
  this.preActiveLineElements = [];
1669
1744
  this.positionedLineElements = [];
1670
1745
  this.activeGapLineElements = [];
1746
+ this.clearBackgroundExpandedLine();
1671
1747
 
1672
1748
  // Stop all running animations and clear highlights immediately
1673
1749
  if (this.lyricsContainer) {
1674
1750
  const activeLines = this.lyricsContainer.querySelectorAll(
1675
- '.lyrics-line.active, .lyrics-line.pre-active, .lyrics-line.bg-expanded',
1751
+ '.lyrics-line.active, .lyrics-line.pre-active, .lyrics-line.bg-expanded, .lyrics-line.scroll-exiting',
1676
1752
  );
1677
1753
  activeLines.forEach(line => {
1678
- line.classList.remove('active', 'pre-active', 'bg-expanded');
1754
+ line.classList.remove(
1755
+ 'active',
1756
+ 'pre-active',
1757
+ 'bg-expanded',
1758
+ 'scroll-exiting',
1759
+ );
1679
1760
  AmLyrics.resetSyllables(line as HTMLElement);
1680
1761
  });
1681
1762
 
@@ -1740,10 +1821,7 @@ export class AmLyrics extends LitElement {
1740
1821
  (switchBtn as HTMLButtonElement).disabled = this.isFetchingAlternatives;
1741
1822
  }
1742
1823
  if (svgEl) {
1743
- svgEl.setAttribute(
1744
- 'style',
1745
- `margin-right: 4px; ${this.isFetchingAlternatives ? 'animation: spin 1s linear infinite;' : ''}`,
1746
- );
1824
+ svgEl.classList.toggle('is-loading', this.isFetchingAlternatives);
1747
1825
  }
1748
1826
  if (labelEl) {
1749
1827
  labelEl.textContent = this.isFetchingAlternatives
@@ -1806,6 +1884,7 @@ export class AmLyrics extends LitElement {
1806
1884
  groupGrowable: boolean[];
1807
1885
  groupGlowing: boolean[];
1808
1886
  groupCharRise: boolean[];
1887
+ groupCharDrag: boolean[];
1809
1888
  vwFullText: string[];
1810
1889
  vwFullDuration: number[];
1811
1890
  vwCharOffset: number[];
@@ -1821,6 +1900,8 @@ export class AmLyrics extends LitElement {
1821
1900
 
1822
1901
  private lastPrimaryActiveLine: HTMLElement | null = null;
1823
1902
 
1903
+ private backgroundExpandedLine: HTMLElement | null = null;
1904
+
1824
1905
  // Scroll animation state
1825
1906
  private scrollAnimationState: {
1826
1907
  isAnimating: boolean;
@@ -2076,6 +2157,7 @@ export class AmLyrics extends LitElement {
2076
2157
  this.preActiveLineElements = [];
2077
2158
  this.positionedLineElements = [];
2078
2159
  this.activeGapLineElements = [];
2160
+ this.clearBackgroundExpandedLine();
2079
2161
 
2080
2162
  if (this.lyricsContainer) {
2081
2163
  this.isProgrammaticScroll = true;
@@ -2140,20 +2222,26 @@ export class AmLyrics extends LitElement {
2140
2222
  return 30;
2141
2223
  }
2142
2224
 
2225
+ private static getDisplaySourceLabel(sourceLabel: string): string {
2226
+ return sourceLabel.toLowerCase().includes('lyricsplus')
2227
+ ? 'QQ'
2228
+ : sourceLabel;
2229
+ }
2230
+
2231
+ private static getSourceKey(sourceLabel: string | null | undefined): string {
2232
+ const lower = (sourceLabel || '').trim().toLowerCase();
2233
+ if (!lower) return '';
2234
+ if (lower.includes('lyricsplus') || lower === 'qq') return 'qq';
2235
+ return lower.replace(/\s+/g, ' ');
2236
+ }
2237
+
2143
2238
  private static mergeAndSortSources(
2144
- collectedSources: { lines: LyricsLine[]; source: string }[],
2145
- ): { lines: LyricsLine[]; source: string }[] {
2146
- const uniqueSourcesMap = new Map<
2147
- string,
2148
- { lines: LyricsLine[]; source: string }
2149
- >();
2239
+ collectedSources: YouLyPlusLyricsResult[],
2240
+ ): YouLyPlusLyricsResult[] {
2241
+ const uniqueSourcesMap = new Map<string, YouLyPlusLyricsResult>();
2150
2242
 
2151
2243
  for (const source of collectedSources) {
2152
- const normalizedSource = source.source
2153
- .toLowerCase()
2154
- .includes('lyricsplus')
2155
- ? 'QQ'
2156
- : source.source;
2244
+ const normalizedSource = AmLyrics.getDisplaySourceLabel(source.source);
2157
2245
 
2158
2246
  if (!uniqueSourcesMap.has(normalizedSource)) {
2159
2247
  uniqueSourcesMap.set(normalizedSource, {
@@ -2170,9 +2258,63 @@ export class AmLyrics extends LitElement {
2170
2258
  );
2171
2259
  }
2172
2260
 
2261
+ private findCurrentSourceIndex(
2262
+ sources = this.availableSources,
2263
+ sourceLabel = this.lyricsSource,
2264
+ lines = this.lyrics,
2265
+ ): number {
2266
+ const identityIndex = sources.findIndex(source => source.lines === lines);
2267
+ if (identityIndex !== -1) return identityIndex;
2268
+
2269
+ const sourceKey = AmLyrics.getSourceKey(sourceLabel);
2270
+ if (!sourceKey) return -1;
2271
+
2272
+ return sources.findIndex(
2273
+ source => AmLyrics.getSourceKey(source.source) === sourceKey,
2274
+ );
2275
+ }
2276
+
2277
+ private static getNextSourceIndex(
2278
+ sources: YouLyPlusLyricsResult[],
2279
+ currentIndex: number,
2280
+ currentSourceLabel: string | null,
2281
+ currentLines: LyricsLine[] | undefined,
2282
+ ): number {
2283
+ if (sources.length <= 1) return -1;
2284
+
2285
+ if (currentIndex !== -1) {
2286
+ return (currentIndex + 1) % sources.length;
2287
+ }
2288
+
2289
+ const currentKey = AmLyrics.getSourceKey(currentSourceLabel);
2290
+ const fallbackIndex = sources.findIndex(
2291
+ source =>
2292
+ source.lines !== currentLines &&
2293
+ AmLyrics.getSourceKey(source.source) !== currentKey,
2294
+ );
2295
+
2296
+ return fallbackIndex === -1 ? 0 : fallbackIndex;
2297
+ }
2298
+
2299
+ private async applySourceAtIndex(index: number) {
2300
+ const sourceResult = this.availableSources[index];
2301
+ if (!sourceResult) return;
2302
+
2303
+ this.currentSourceIndex = index;
2304
+ this.lyrics = sourceResult.lines;
2305
+ this.lyricsSource = sourceResult.source;
2306
+ if (sourceResult.songwriters) {
2307
+ this.songwriters = sourceResult.songwriters;
2308
+ }
2309
+ await this.onLyricsLoaded();
2310
+ }
2311
+
2173
2312
  private async switchSource() {
2174
2313
  if (this.isFetchingAlternatives) return;
2175
2314
 
2315
+ const currentSourceLabel = this.lyricsSource;
2316
+ const currentLines = this.lyrics;
2317
+
2176
2318
  if (!this.hasFetchedAllProviders) {
2177
2319
  this.isFetchingAlternatives = true;
2178
2320
  this._updateFooter();
@@ -2249,11 +2391,13 @@ export class AmLyrics extends LitElement {
2249
2391
  ...this.availableSources,
2250
2392
  ...newSources,
2251
2393
  ]);
2252
- // Re-sync current index since sorting might shift elements
2253
- this.currentSourceIndex = this.availableSources.findIndex(
2254
- s => s.source === this.lyricsSource,
2394
+ // Re-sync current index since sorting or label normalization can
2395
+ // shift the currently displayed source underneath the old index.
2396
+ this.currentSourceIndex = this.findCurrentSourceIndex(
2397
+ this.availableSources,
2398
+ currentSourceLabel,
2399
+ currentLines,
2255
2400
  );
2256
- if (this.currentSourceIndex === -1) this.currentSourceIndex = 0;
2257
2401
  }
2258
2402
  }
2259
2403
  } finally {
@@ -2264,15 +2408,20 @@ export class AmLyrics extends LitElement {
2264
2408
  }
2265
2409
 
2266
2410
  if (this.availableSources.length > 1) {
2267
- this.currentSourceIndex =
2268
- (this.currentSourceIndex + 1) % this.availableSources.length;
2269
- const sourceResult = this.availableSources[this.currentSourceIndex];
2270
- this.lyrics = sourceResult.lines;
2271
- this.lyricsSource = sourceResult.source;
2272
- if (sourceResult.songwriters) {
2273
- this.songwriters = sourceResult.songwriters;
2411
+ const currentIndex = this.findCurrentSourceIndex(
2412
+ this.availableSources,
2413
+ currentSourceLabel,
2414
+ currentLines,
2415
+ );
2416
+ const nextIndex = AmLyrics.getNextSourceIndex(
2417
+ this.availableSources,
2418
+ currentIndex,
2419
+ currentSourceLabel,
2420
+ currentLines,
2421
+ );
2422
+ if (nextIndex !== -1) {
2423
+ await this.applySourceAtIndex(nextIndex);
2274
2424
  }
2275
- await this.onLyricsLoaded();
2276
2425
  }
2277
2426
  }
2278
2427
 
@@ -3538,13 +3687,21 @@ export class AmLyrics extends LitElement {
3538
3687
  if (!newActiveLines.includes(lineIndex)) {
3539
3688
  const lineElement = this._getLineElement(lineIndex);
3540
3689
  if (lineElement) {
3541
- if (isSeek || this.isUserScrolling) {
3690
+ if (
3691
+ isSeek ||
3692
+ this.isUserScrolling ||
3693
+ AmLyrics.isLineSyncedLine(this.lyrics?.[lineIndex])
3694
+ ) {
3542
3695
  AmLyrics.unfinishSyllables(lineElement);
3543
3696
  } else {
3544
3697
  AmLyrics.finishSyllablesUpToTime(lineElement, newTime);
3545
3698
  }
3546
3699
 
3547
- lineElement.classList.remove('active', 'bg-expanded');
3700
+ lineElement.classList.remove(
3701
+ 'active',
3702
+ 'bg-expanded',
3703
+ 'scroll-exiting',
3704
+ );
3548
3705
 
3549
3706
  if (lineElement.classList.contains('pre-active')) {
3550
3707
  lineElement.classList.remove('pre-active');
@@ -3555,13 +3712,14 @@ export class AmLyrics extends LitElement {
3555
3712
  }
3556
3713
  }
3557
3714
 
3558
- // Add 'active' and 'bg-expanded' to newly active lines
3715
+ // Add 'active' to newly active lines. Background expansion is driven
3716
+ // separately by the current scroll target.
3559
3717
  for (const lineIndex of newActiveLines) {
3560
3718
  if (!oldActiveLines.includes(lineIndex)) {
3561
3719
  const lineElement = this._getLineElement(lineIndex);
3562
3720
  if (lineElement) {
3563
- lineElement.classList.add('active', 'bg-expanded');
3564
- lineElement.classList.remove('pre-active');
3721
+ lineElement.classList.add('active');
3722
+ lineElement.classList.remove('pre-active', 'scroll-exiting');
3565
3723
  const preIdx = this.preActiveLineElements.indexOf(lineElement);
3566
3724
  if (preIdx !== -1) this.preActiveLineElements.splice(preIdx, 1);
3567
3725
  }
@@ -3810,7 +3968,13 @@ export class AmLyrics extends LitElement {
3810
3968
  }
3811
3969
 
3812
3970
  updated(changedProperties: Map<string | number | symbol, unknown>) {
3813
- if (changedProperties.has('lyrics')) {
3971
+ const lyricsDomBecameRenderable =
3972
+ changedProperties.has('lyrics') ||
3973
+ (changedProperties.has('isLoading') &&
3974
+ !this.isLoading &&
3975
+ Boolean(this.lyrics));
3976
+
3977
+ if (lyricsDomBecameRenderable) {
3814
3978
  this._invalidateCaches();
3815
3979
  this._ensureLineDataCache();
3816
3980
  this._updateCachedIsUnsynced();
@@ -3824,8 +3988,14 @@ export class AmLyrics extends LitElement {
3824
3988
  const activeLines = this.findActiveLineIndices(this.currentTime);
3825
3989
  for (const lineIndex of activeLines) {
3826
3990
  const lineEl = this._getLineElement(lineIndex);
3827
- if (lineEl) lineEl.classList.add('active', 'bg-expanded');
3991
+ if (lineEl) lineEl.classList.add('active');
3828
3992
  }
3993
+ const primaryActiveLine = this.getPrimaryActiveLineIndex(activeLines);
3994
+ this.setBackgroundExpandedLine(
3995
+ primaryActiveLine !== null
3996
+ ? this._getLineElement(primaryActiveLine)
3997
+ : null,
3998
+ );
3829
3999
 
3830
4000
  // Trigger a faux time-change so that updateSyllablesForLine fires
3831
4001
  // to setup inline syllable CSS wipe animations for whatever the current time is
@@ -3872,6 +4042,7 @@ export class AmLyrics extends LitElement {
3872
4042
  this.preActiveLineElements = [];
3873
4043
  this.positionedLineElements = [];
3874
4044
  this.activeGapLineElements = [];
4045
+ this.clearBackgroundExpandedLine();
3875
4046
  this.setUserScrolling(false);
3876
4047
 
3877
4048
  // Cancel any running animations
@@ -3943,6 +4114,7 @@ export class AmLyrics extends LitElement {
3943
4114
  // Don't override it with a scroll back to the last lyric.
3944
4115
  const footer = this.lyricsContainer.querySelector('.lyrics-footer');
3945
4116
  if (footer?.classList.contains('active')) {
4117
+ this.setBackgroundExpandedLine(null);
3946
4118
  return;
3947
4119
  }
3948
4120
 
@@ -3994,10 +4166,22 @@ export class AmLyrics extends LitElement {
3994
4166
  targetElement = this._getLineElement(targetLineIdx);
3995
4167
  }
3996
4168
  }
3997
- if (!targetElement) return;
4169
+ if (!targetElement) {
4170
+ this.setBackgroundExpandedLine(null);
4171
+ return;
4172
+ }
4173
+
4174
+ const scrollDuration = scrollLookAheadMs;
4175
+ if (targetElement !== this.currentPrimaryActiveLine || forceScroll) {
4176
+ targetElement.style.setProperty(
4177
+ '--scroll-duration',
4178
+ `${scrollDuration}ms`,
4179
+ );
4180
+ }
4181
+ this.setBackgroundExpandedLine(targetElement);
3998
4182
 
3999
- // Unblur the upcoming target line early (pre-active) so background
4000
- // vocals start their max-height/opacity transition in sync with scroll.
4183
+ // Unblur the upcoming target line early while the separate bg-expanded
4184
+ // class starts background vocal height/opacity in sync with scroll.
4001
4185
  if (!targetElement.classList.contains('active')) {
4002
4186
  targetElement.classList.add('pre-active');
4003
4187
  if (!this.preActiveLineElements.includes(targetElement)) {
@@ -4005,8 +4189,6 @@ export class AmLyrics extends LitElement {
4005
4189
  }
4006
4190
  }
4007
4191
 
4008
- const scrollDuration = scrollLookAheadMs;
4009
-
4010
4192
  this.focusLine(targetElement, forceScroll, scrollDuration);
4011
4193
  }
4012
4194
 
@@ -4096,6 +4278,7 @@ export class AmLyrics extends LitElement {
4096
4278
  this.preActiveLineElements = [];
4097
4279
  this.positionedLineElements = [];
4098
4280
  this.activeGapLineElements = [];
4281
+ this.clearBackgroundExpandedLine();
4099
4282
  this.visibilityObserver?.disconnect();
4100
4283
  this.visibilityObserver = undefined;
4101
4284
  }
@@ -4139,6 +4322,7 @@ export class AmLyrics extends LitElement {
4139
4322
  const groupGrowable: boolean[] = new Array(wordGroups.length).fill(false);
4140
4323
  const groupGlowing: boolean[] = new Array(wordGroups.length).fill(false);
4141
4324
  const groupCharRise: boolean[] = new Array(wordGroups.length).fill(false);
4325
+ const groupCharDrag: boolean[] = new Array(wordGroups.length).fill(false);
4142
4326
  const vwFullText: string[] = new Array(wordGroups.length).fill('');
4143
4327
  const vwFullDuration: number[] = new Array(wordGroups.length).fill(0);
4144
4328
  const vwCharOffset: number[] = new Array(wordGroups.length).fill(0);
@@ -4183,9 +4367,13 @@ export class AmLyrics extends LitElement {
4183
4367
  line.isWordSynced === false || line.text.some(s => s.lineSynced);
4184
4368
  let isGrowableVW = canAnimateByChar && wordLen > 0 && wordLen <= 7;
4185
4369
  if (isGrowableVW) {
4186
- if (wordLen < 3) {
4370
+ if (wordLen <= 1) {
4187
4371
  isGrowableVW =
4188
4372
  combinedDuration >= 1050 && combinedDuration >= wordLen * 525;
4373
+ } else if (wordLen <= 3) {
4374
+ isGrowableVW =
4375
+ combinedDuration >=
4376
+ SHORT_WORD_GLOW_MIN_DURATION_MS + (wordLen - 2) * 140;
4189
4377
  } else {
4190
4378
  isGrowableVW =
4191
4379
  combinedDuration >= 850 && combinedDuration >= wordLen * 190;
@@ -4194,6 +4382,11 @@ export class AmLyrics extends LitElement {
4194
4382
 
4195
4383
  const hasCharRiseDuration =
4196
4384
  combinedDuration >= Math.max(700, wordLen * 85);
4385
+ const hasTinyWordDragDuration =
4386
+ wordLen >= 2 &&
4387
+ wordLen <= 3 &&
4388
+ combinedDuration >=
4389
+ Math.max(SHORT_WORD_DRAG_MIN_DURATION_MS, wordLen * 150);
4197
4390
  const hasLongShortWordDuration =
4198
4391
  wordLen >= 4 && combinedDuration >= Math.max(1300, wordLen * 260);
4199
4392
  const isCharRiseVW =
@@ -4202,6 +4395,11 @@ export class AmLyrics extends LitElement {
4202
4395
  !isGrowableVW &&
4203
4396
  ((wordLen >= 8 && hasCharRiseDuration) ||
4204
4397
  (wordLen < 8 && hasLongShortWordDuration));
4398
+ const isCharDragVW =
4399
+ canAnimateByChar &&
4400
+ !isLineSynced &&
4401
+ !isGrowableVW &&
4402
+ hasTinyWordDragDuration;
4205
4403
 
4206
4404
  const isGlowingVW = isGrowableVW && !isLineSynced;
4207
4405
 
@@ -4210,6 +4408,7 @@ export class AmLyrics extends LitElement {
4210
4408
  groupGrowable[gi] = isGrowableVW;
4211
4409
  groupGlowing[gi] = isGlowingVW;
4212
4410
  groupCharRise[gi] = isCharRiseVW;
4411
+ groupCharDrag[gi] = isCharDragVW;
4213
4412
  vwFullText[gi] = combinedText;
4214
4413
  vwFullDuration[gi] = combinedDuration;
4215
4414
  vwCharOffset[gi] = charOff;
@@ -4227,6 +4426,7 @@ export class AmLyrics extends LitElement {
4227
4426
  groupGrowable,
4228
4427
  groupGlowing,
4229
4428
  groupCharRise,
4429
+ groupCharDrag,
4230
4430
  vwFullText,
4231
4431
  vwFullDuration,
4232
4432
  vwCharOffset,
@@ -4248,62 +4448,146 @@ export class AmLyrics extends LitElement {
4248
4448
 
4249
4449
  const computedStyle = getComputedStyle(referenceSyllable);
4250
4450
  const { font } = computedStyle; // Full font string
4251
- const fontSize = parseFloat(computedStyle.fontSize);
4252
-
4253
- const charTimedWords = this.shadowRoot.querySelectorAll(
4254
- '.lyrics-word.growable, .lyrics-word.char-rise',
4255
- );
4256
- if (!charTimedWords) return;
4451
+ const fontSize = Number.parseFloat(computedStyle.fontSize) || 16;
4257
4452
 
4258
- charTimedWords.forEach((wordSpan: any) => {
4259
- const syllableWraps = wordSpan.querySelectorAll('.lyrics-syllable-wrap');
4453
+ const charTimedWords = Array.from(
4454
+ this.shadowRoot.querySelectorAll(
4455
+ '.lyrics-word.growable, .lyrics-word.char-rise, .lyrics-word.char-drag',
4456
+ ),
4457
+ ) as HTMLElement[];
4458
+ if (charTimedWords.length === 0) return;
4459
+
4460
+ const wordsByVirtualId = new Map<string, HTMLElement[]>();
4461
+ charTimedWords.forEach((wordSpan, index) => {
4462
+ const virtualWordId = wordSpan.dataset.virtualWordId || `word-${index}`;
4463
+ const words = wordsByVirtualId.get(virtualWordId);
4464
+ if (words) {
4465
+ words.push(wordSpan);
4466
+ } else {
4467
+ wordsByVirtualId.set(virtualWordId, [wordSpan]);
4468
+ }
4469
+ });
4260
4470
 
4261
- // Flatten syllables
4471
+ wordsByVirtualId.forEach(wordSpans => {
4262
4472
  const syllables: HTMLElement[] = [];
4263
- syllableWraps.forEach((wrap: HTMLElement) => {
4264
- const syl = wrap.querySelector('.lyrics-syllable');
4265
- if (syl) syllables.push(syl as HTMLElement);
4473
+ wordSpans.forEach(wordSpan => {
4474
+ const syllableWraps = wordSpan.querySelectorAll(
4475
+ '.lyrics-syllable-wrap',
4476
+ );
4477
+ syllableWraps.forEach(wrap => {
4478
+ const syl = wrap.querySelector('.lyrics-syllable');
4479
+ if (syl) syllables.push(syl as HTMLElement);
4480
+ });
4266
4481
  });
4267
4482
 
4268
- syllables.forEach(sylSpan => {
4269
- const charSpans = sylSpan.querySelectorAll('.char');
4270
- if (charSpans.length === 0) return;
4271
-
4272
- // Logic from YouLyPlus renderCharWipes:
4273
- // Use textContent from spans to ensure we measure what is rendered
4274
- const chars = Array.from(charSpans).map(span => span.textContent || '');
4275
- const charWidths = chars.map(c => this._getTextWidth(c, font));
4276
- const totalSyllableWidth = charWidths.reduce((a, b) => a + b, 0);
4277
-
4278
- const duration = parseFloat(sylSpan.dataset.duration || '0');
4279
- const velocityPxPerMs =
4280
- duration > 0 ? totalSyllableWidth / duration : 0;
4281
-
4282
- // Gradient width in pixels = 0.375 * fontSize
4283
- // This matches YouLyPlus visual gradient size
4284
- const gradientWidthPx = 0.375 * fontSize;
4285
- const gradientDurationMs =
4286
- velocityPxPerMs > 0 ? gradientWidthPx / velocityPxPerMs : 100;
4287
-
4288
- let cumulativeCharWidth = 0;
4289
-
4290
- charSpans.forEach((spanArg: any, i: number) => {
4291
- const charWidth = charWidths[i];
4292
- const span = spanArg;
4483
+ const charSpans = syllables.flatMap(syl => {
4484
+ const spans = Array.from(
4485
+ syl.querySelectorAll('.char'),
4486
+ ) as HTMLElement[];
4487
+ const target = syl as any;
4488
+ target._cachedCharSpans = spans;
4489
+ return spans;
4490
+ });
4491
+ if (charSpans.length === 0) return;
4293
4492
 
4294
- if (totalSyllableWidth > 0) {
4295
- const startPercent = cumulativeCharWidth / totalSyllableWidth;
4296
- const durationPercent = charWidth / totalSyllableWidth;
4493
+ wordSpans.forEach(wordSpan => {
4494
+ const target = wordSpan as any;
4495
+ target._cachedVirtualWordElements = wordSpans;
4496
+ target._cachedVirtualWordCharSpans = charSpans;
4497
+ });
4297
4498
 
4298
- span.dataset.wipeStart = startPercent.toFixed(4);
4299
- span.dataset.wipeDuration = durationPercent.toFixed(4);
4499
+ const syllableEntries = syllables.map(syl => {
4500
+ const spans = (syl as any)._cachedCharSpans as HTMLElement[];
4501
+ const charWidths = spans.map(span =>
4502
+ this._getTextWidth(span.textContent || '', font),
4503
+ );
4504
+ const totalWidth = charWidths.reduce((a, b) => a + b, 0);
4505
+ return {
4506
+ syl,
4507
+ spans,
4508
+ charWidths,
4509
+ totalWidth,
4510
+ start: parseFloat(syl.getAttribute('data-start-time') || ''),
4511
+ end: parseFloat(syl.getAttribute('data-end-time') || ''),
4512
+ };
4513
+ });
4514
+ const totalWordWidth = syllableEntries.reduce(
4515
+ (total, entry) => total + entry.totalWidth,
4516
+ 0,
4517
+ );
4518
+ if (totalWordWidth <= 0) return;
4300
4519
 
4301
- // The critical missing piece:
4302
- span.dataset.preWipeArrival = (duration * startPercent).toFixed(2);
4303
- span.dataset.preWipeDuration = gradientDurationMs.toFixed(2);
4520
+ const virtualWordStart = Math.min(
4521
+ ...syllableEntries
4522
+ .map(entry => entry.start)
4523
+ .filter(start => Number.isFinite(start)),
4524
+ );
4525
+ const virtualWordEnd = Math.max(
4526
+ ...syllableEntries
4527
+ .map(entry => entry.end)
4528
+ .filter(end => Number.isFinite(end)),
4529
+ );
4530
+ const virtualWordDuration = virtualWordEnd - virtualWordStart;
4531
+ const hasTimedSyllables =
4532
+ Number.isFinite(virtualWordStart) &&
4533
+ Number.isFinite(virtualWordEnd) &&
4534
+ virtualWordDuration > 0;
4535
+ const wordVelocityPxPerMs = hasTimedSyllables
4536
+ ? totalWordWidth / virtualWordDuration
4537
+ : 0;
4538
+ const gradientLeadWidthPx =
4539
+ (BASE_WIPE_GRADIENT_EM * Math.max(1, fontSize)) / 2;
4540
+ const measuredPreWipeDuration =
4541
+ wordVelocityPxPerMs > 0
4542
+ ? gradientLeadWidthPx / wordVelocityPxPerMs
4543
+ : 100;
4544
+
4545
+ let cumulativeCharWidth = 0;
4546
+ syllableEntries.forEach(entry => {
4547
+ let cumulativeSyllableWidth = 0;
4548
+ const syllableDuration = entry.end - entry.start;
4549
+ const useSyllableTiming =
4550
+ hasTimedSyllables &&
4551
+ Number.isFinite(entry.start) &&
4552
+ Number.isFinite(entry.end) &&
4553
+ syllableDuration > 0 &&
4554
+ entry.totalWidth > 0;
4555
+
4556
+ entry.spans.forEach((span, index) => {
4557
+ const charWidth = entry.charWidths[index];
4558
+ let startPercent = cumulativeCharWidth / totalWordWidth;
4559
+ let durationPercent = charWidth / totalWordWidth;
4560
+ if (useSyllableTiming) {
4561
+ const charStartMs =
4562
+ entry.start -
4563
+ virtualWordStart +
4564
+ (cumulativeSyllableWidth / entry.totalWidth) * syllableDuration;
4565
+ const charDurationMs =
4566
+ (charWidth / entry.totalWidth) * syllableDuration;
4567
+ startPercent = AmLyrics.clamp(
4568
+ charStartMs / virtualWordDuration,
4569
+ 0,
4570
+ 1,
4571
+ );
4572
+ durationPercent = AmLyrics.clamp(
4573
+ charDurationMs / virtualWordDuration,
4574
+ 0,
4575
+ 1,
4576
+ );
4304
4577
  }
4578
+ const target = span;
4579
+
4580
+ target.dataset.wipeStart = startPercent.toFixed(4);
4581
+ target.dataset.wipeDuration = durationPercent.toFixed(4);
4582
+ target.dataset.preWipeDuration = measuredPreWipeDuration.toFixed(2);
4583
+ target.style.setProperty('--word-wipe-width', `${totalWordWidth}px`);
4584
+ target.style.setProperty(
4585
+ '--char-wipe-position',
4586
+ `${-cumulativeCharWidth}px`,
4587
+ );
4305
4588
 
4306
4589
  cumulativeCharWidth += charWidth;
4590
+ cumulativeSyllableWidth += charWidth;
4307
4591
  });
4308
4592
  });
4309
4593
  });
@@ -4313,6 +4597,37 @@ export class AmLyrics extends LitElement {
4313
4597
  return a.length === b.length && a.every((val, i) => val === b[i]);
4314
4598
  }
4315
4599
 
4600
+ private static isLineSyncedLine(line: LyricsLine | undefined): boolean {
4601
+ if (!line) return false;
4602
+ return line.isWordSynced === false || line.text.some(s => s.lineSynced);
4603
+ }
4604
+
4605
+ private getLineHighlightEndTime(index: number): number {
4606
+ if (!this.lyrics) return 0;
4607
+ const line = this.lyrics[index];
4608
+ if (!line) return 0;
4609
+
4610
+ const rawEnd = Math.max(line.endtime, line.timestamp);
4611
+
4612
+ const nextLine = this.lyrics[index + 1];
4613
+ if (!nextLine || nextLine.timestamp <= line.timestamp) {
4614
+ return rawEnd > line.timestamp ? rawEnd + 200 : rawEnd;
4615
+ }
4616
+
4617
+ if (rawEnd > line.timestamp) {
4618
+ if (nextLine.timestamp < rawEnd) {
4619
+ return rawEnd;
4620
+ }
4621
+
4622
+ const gapToNext = nextLine.timestamp - rawEnd;
4623
+ if (gapToNext >= INSTRUMENTAL_THRESHOLD_MS) {
4624
+ return rawEnd;
4625
+ }
4626
+ }
4627
+
4628
+ return nextLine.timestamp;
4629
+ }
4630
+
4316
4631
  private static getLineIndexFromElement(
4317
4632
  lineElement: HTMLElement | null,
4318
4633
  ): number | null {
@@ -4350,6 +4665,31 @@ export class AmLyrics extends LitElement {
4350
4665
  this.preActiveLineElements = keptLines;
4351
4666
  }
4352
4667
 
4668
+ private setBackgroundExpandedLine(lineElement: HTMLElement | null): void {
4669
+ const target =
4670
+ lineElement &&
4671
+ !lineElement.classList.contains('lyrics-gap') &&
4672
+ lineElement.querySelector('.background-vocal-container')
4673
+ ? lineElement
4674
+ : null;
4675
+
4676
+ if (this.backgroundExpandedLine === target) {
4677
+ if (target && !target.classList.contains('bg-expanded')) {
4678
+ target.classList.add('bg-expanded');
4679
+ }
4680
+ return;
4681
+ }
4682
+
4683
+ this.backgroundExpandedLine?.classList.remove('bg-expanded');
4684
+ this.backgroundExpandedLine = target;
4685
+ target?.classList.add('bg-expanded');
4686
+ }
4687
+
4688
+ private clearBackgroundExpandedLine(): void {
4689
+ this.backgroundExpandedLine?.classList.remove('bg-expanded');
4690
+ this.backgroundExpandedLine = null;
4691
+ }
4692
+
4353
4693
  private getPrimaryActiveLineIndex(activeIndices: number[]): number | null {
4354
4694
  if (activeIndices.length === 0) return null;
4355
4695
 
@@ -4463,7 +4803,15 @@ export class AmLyrics extends LitElement {
4463
4803
  // effectiveEndTimes). Lines stay active until their extended end,
4464
4804
  // so we no longer need to remove .active here.
4465
4805
  this.lastPrimaryActiveLine = this.currentPrimaryActiveLine;
4806
+ if (this.lastPrimaryActiveLine) {
4807
+ this.lastPrimaryActiveLine.style.setProperty(
4808
+ '--scroll-duration',
4809
+ `${scrollDuration ?? SCROLL_ANIMATION_DURATION_MS}ms`,
4810
+ );
4811
+ this.lastPrimaryActiveLine.classList.add('scroll-exiting');
4812
+ }
4466
4813
  this.currentPrimaryActiveLine = lineElement;
4814
+ this.currentPrimaryActiveLine.classList.remove('scroll-exiting');
4467
4815
  const lineIndex = AmLyrics.getLineIndexFromElement(lineElement);
4468
4816
  if (lineIndex !== null) {
4469
4817
  this.lastActiveIndex = lineIndex;
@@ -4593,9 +4941,10 @@ export class AmLyrics extends LitElement {
4593
4941
 
4594
4942
  for (let i = 0; i < this.lyrics.length; i += 1) {
4595
4943
  const line = this.lyrics[i];
4944
+ const highlightEndTime = this.getLineHighlightEndTime(i);
4596
4945
 
4597
4946
  if (line.timestamp > time) break;
4598
- if (time >= line.timestamp && time < line.endtime) {
4947
+ if (time >= line.timestamp && time < highlightEndTime) {
4599
4948
  activeLines.push(i);
4600
4949
  }
4601
4950
  }
@@ -4830,7 +5179,7 @@ export class AmLyrics extends LitElement {
4830
5179
  allLines.forEach(lineEl => {
4831
5180
  AmLyrics.resetSyllables(lineEl as HTMLElement);
4832
5181
  // Remove scroll-animate class and properties to stop any scroll animations
4833
- lineEl.classList.remove('scroll-animate');
5182
+ lineEl.classList.remove('scroll-animate', 'scroll-exiting');
4834
5183
  (lineEl as HTMLElement).style.removeProperty('--scroll-delta');
4835
5184
  (lineEl as HTMLElement).style.removeProperty('--lyrics-line-delay');
4836
5185
  });
@@ -4862,6 +5211,7 @@ export class AmLyrics extends LitElement {
4862
5211
  this.lastPrimaryActiveLine = null;
4863
5212
  this.activeLineIds.clear();
4864
5213
  this.animatingLines = [];
5214
+ this.setBackgroundExpandedLine(null);
4865
5215
 
4866
5216
  // Find the clicked line element and scroll to it with forceScroll (like YouLyPlus)
4867
5217
  // Timestamps are already in milliseconds — match the data-start-time attribute directly
@@ -4884,6 +5234,7 @@ export class AmLyrics extends LitElement {
4884
5234
  }, 800);
4885
5235
 
4886
5236
  this.scrollToActiveLineYouLy(clickedLineElement, true);
5237
+ this.setBackgroundExpandedLine(clickedLineElement);
4887
5238
  }
4888
5239
 
4889
5240
  const event = new CustomEvent('line-click', {
@@ -5112,6 +5463,7 @@ export class AmLyrics extends LitElement {
5112
5463
  scrollDuration ?? SCROLL_ANIMATION_DURATION_MS,
5113
5464
  );
5114
5465
  const delayIncrement = duration * 0.1;
5466
+ const maxStaggerSteps = 4;
5115
5467
  const lookAhead = 20;
5116
5468
  const len = lineArray.length;
5117
5469
 
@@ -5120,13 +5472,17 @@ export class AmLyrics extends LitElement {
5120
5472
 
5121
5473
  let maxAnimationDuration = 0;
5122
5474
  const newAnimatingLines: HTMLElement[] = [];
5475
+ const lineDelays = new Map<HTMLElement, number>();
5123
5476
  const scrollingDown = delta >= 0;
5124
5477
 
5125
5478
  if (scrollingDown) {
5126
5479
  let delayCounter = 0;
5127
5480
  for (let i = start; i < end; i += 1) {
5128
5481
  const line = lineArray[i];
5129
- const delay = i >= referenceIndex ? delayCounter * delayIncrement : 0;
5482
+ const delay =
5483
+ i >= referenceIndex
5484
+ ? Math.min(delayCounter, maxStaggerSteps) * delayIncrement
5485
+ : 0;
5130
5486
 
5131
5487
  if (i >= referenceIndex && !line.classList.contains('lyrics-gap')) {
5132
5488
  delayCounter += 1;
@@ -5134,11 +5490,11 @@ export class AmLyrics extends LitElement {
5134
5490
 
5135
5491
  line.style.setProperty('--scroll-delta', `${delta}px`);
5136
5492
  line.style.setProperty('--lyrics-line-delay', `${delay}ms`);
5137
- line.style.setProperty('--scroll-duration', `${duration + 100}ms`);
5493
+ lineDelays.set(line, delay);
5138
5494
 
5139
5495
  newAnimatingLines.push(line);
5140
5496
 
5141
- const lineDuration = duration + delay;
5497
+ const lineDuration = duration + 100 + delay;
5142
5498
  if (lineDuration > maxAnimationDuration) {
5143
5499
  maxAnimationDuration = lineDuration;
5144
5500
  }
@@ -5147,7 +5503,10 @@ export class AmLyrics extends LitElement {
5147
5503
  let delayCounter = 0;
5148
5504
  for (let i = end - 1; i >= start; i -= 1) {
5149
5505
  const line = lineArray[i];
5150
- const delay = i <= referenceIndex ? delayCounter * delayIncrement : 0;
5506
+ const delay =
5507
+ i <= referenceIndex
5508
+ ? Math.min(delayCounter, maxStaggerSteps) * delayIncrement
5509
+ : 0;
5151
5510
 
5152
5511
  if (i <= referenceIndex && !line.classList.contains('lyrics-gap')) {
5153
5512
  delayCounter += 1;
@@ -5155,17 +5514,27 @@ export class AmLyrics extends LitElement {
5155
5514
 
5156
5515
  line.style.setProperty('--scroll-delta', `${delta}px`);
5157
5516
  line.style.setProperty('--lyrics-line-delay', `${delay}ms`);
5158
- line.style.setProperty('--scroll-duration', `${duration + 100}ms`);
5517
+ lineDelays.set(line, delay);
5159
5518
 
5160
5519
  newAnimatingLines.push(line);
5161
5520
 
5162
- const lineDuration = duration + delay;
5521
+ const lineDuration = duration + 100 + delay;
5163
5522
  if (lineDuration > maxAnimationDuration) {
5164
5523
  maxAnimationDuration = lineDuration;
5165
5524
  }
5166
5525
  }
5167
5526
  }
5168
5527
 
5528
+ /* Preserve the staggered starts, but make every line settle together.
5529
+ This keeps the selected line from drifting after its neighbours. */
5530
+ for (const line of newAnimatingLines) {
5531
+ const delay = lineDelays.get(line) ?? 0;
5532
+ line.style.setProperty(
5533
+ '--scroll-duration',
5534
+ `${Math.max(100, maxAnimationDuration - delay)}ms`,
5535
+ );
5536
+ }
5537
+
5169
5538
  // --- Step 3: Force reflow so the browser sees the class removal ---
5170
5539
  // Use offsetHeight which is cheaper than getBoundingClientRect
5171
5540
  // eslint-disable-next-line no-void
@@ -5322,44 +5691,419 @@ export class AmLyrics extends LitElement {
5322
5691
 
5323
5692
  /**
5324
5693
  * Update syllable highlight animation - apply CSS wipe animation
5325
- * (Exact copy from YouLyPlus _updateSyllableAnimation)
5326
5694
  */
5327
- private static updateSyllableAnimation(
5695
+ private static clamp(value: number, min: number, max: number): number {
5696
+ return Math.min(max, Math.max(min, value));
5697
+ }
5698
+
5699
+ private static getVisibleCharacterCount(element: HTMLElement): number {
5700
+ const attrLength = parseFloat(
5701
+ element.getAttribute('data-word-length') || '',
5702
+ );
5703
+ if (Number.isFinite(attrLength) && attrLength > 0) return attrLength;
5704
+ return (element.textContent || '').replace(/\s/g, '').length;
5705
+ }
5706
+
5707
+ private static getLongWordWipeScale(charCount: number): number {
5708
+ if (charCount <= 6) return 1;
5709
+ return (
5710
+ 1 +
5711
+ AmLyrics.clamp((charCount - 6) / 10, 0, 1) * LONG_WORD_WIPE_EXTRA_RATIO
5712
+ );
5713
+ }
5714
+
5715
+ private static applyWipeShape(element: HTMLElement, charCount: number): void {
5716
+ const extra =
5717
+ AmLyrics.clamp((charCount - 6) / 10, 0, 1) * LONG_WORD_WIPE_EXTRA_EM;
5718
+ const width = BASE_WIPE_GRADIENT_EM + extra;
5719
+ element.style.setProperty('--wipe-gradient-width', `${width.toFixed(3)}em`);
5720
+ element.style.setProperty(
5721
+ '--wipe-gradient-half',
5722
+ `${(width / 2).toFixed(3)}em`,
5723
+ );
5724
+ }
5725
+
5726
+ private static ensureWordWipeGeometry(
5727
+ charSpans: HTMLElement[],
5728
+ charCount: number,
5729
+ ): void {
5730
+ if (charSpans.length === 0) return;
5731
+
5732
+ const approxWidthCh = Math.max(1, charCount || charSpans.length);
5733
+ charSpans.forEach((span, index) => {
5734
+ if (!span.style.getPropertyValue('--word-wipe-width')) {
5735
+ span.style.setProperty('--word-wipe-width', `${approxWidthCh}ch`);
5736
+ }
5737
+
5738
+ if (!span.style.getPropertyValue('--char-wipe-position')) {
5739
+ const startPct = Number.parseFloat(
5740
+ span.dataset.wipeStart || `${index / Math.max(1, charSpans.length)}`,
5741
+ );
5742
+ span.style.setProperty(
5743
+ '--char-wipe-position',
5744
+ `${-(AmLyrics.clamp(startPct, 0, 1) * approxWidthCh)}ch`,
5745
+ );
5746
+ }
5747
+ });
5748
+ }
5749
+
5750
+ private static clearPreHighlight(syllable: HTMLElement): void {
5751
+ const target = syllable;
5752
+ target.classList.remove('pre-highlight');
5753
+ target.style.removeProperty('--pre-wipe-duration');
5754
+ target.style.removeProperty('--pre-wipe-delay');
5755
+ target.style.animation = '';
5756
+ target
5757
+ .querySelectorAll('.pre-wipe-lead')
5758
+ .forEach(element => AmLyrics.clearPreWipeLead(element as HTMLElement));
5759
+ }
5760
+
5761
+ private static clearPreWipeLead(element: HTMLElement): void {
5762
+ element.classList.remove('pre-wipe-lead');
5763
+ element.style.removeProperty('--pre-wipe-duration');
5764
+ element.style.removeProperty('--pre-wipe-delay');
5765
+ }
5766
+
5767
+ private static hasTextBoundaryAfter(syllable: HTMLElement): boolean {
5768
+ return /\s$/.test(syllable.textContent || '');
5769
+ }
5770
+
5771
+ private static getSyllableWordIndex(syllable: HTMLElement): string {
5772
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
5773
+ const virtualWordId = wordElement?.dataset.virtualWordId;
5774
+ if (virtualWordId) {
5775
+ return `virtual:${virtualWordId}`;
5776
+ }
5777
+
5778
+ const virtualWordStart = wordElement?.dataset.virtualWordStart;
5779
+ const virtualWordEnd = wordElement?.dataset.virtualWordEnd;
5780
+ if (virtualWordStart || virtualWordEnd) {
5781
+ return `virtual:${virtualWordStart || ''}:${virtualWordEnd || ''}`;
5782
+ }
5783
+
5784
+ return (
5785
+ syllable.getAttribute('data-word-index') ||
5786
+ syllable.getAttribute('data-syllable-index') ||
5787
+ ''
5788
+ );
5789
+ }
5790
+
5791
+ private static getNextWordSyllable(
5792
+ syllables: HTMLElement[],
5793
+ index: number,
5794
+ ): HTMLElement | null {
5795
+ const current = syllables[index];
5796
+ const currentWordIndex = AmLyrics.getSyllableWordIndex(current);
5797
+ const previousSyllable = current;
5798
+
5799
+ for (let i = index + 1; i < syllables.length; i += 1) {
5800
+ const candidate = syllables[i];
5801
+ if (candidate.classList.contains('transliteration')) {
5802
+ // eslint-disable-next-line no-continue
5803
+ continue;
5804
+ }
5805
+
5806
+ const candidateWordIndex = AmLyrics.getSyllableWordIndex(candidate);
5807
+ if (
5808
+ candidateWordIndex === currentWordIndex ||
5809
+ !AmLyrics.hasTextBoundaryAfter(previousSyllable)
5810
+ ) {
5811
+ return null;
5812
+ }
5813
+
5814
+ return candidate;
5815
+ }
5816
+
5817
+ return null;
5818
+ }
5819
+
5820
+ private static getPreviousNonTransliterationSyllable(
5821
+ syllables: HTMLElement[],
5822
+ index: number,
5823
+ ): HTMLElement | null {
5824
+ for (let i = index - 1; i >= 0; i -= 1) {
5825
+ const candidate = syllables[i];
5826
+ if (!candidate.classList.contains('transliteration')) {
5827
+ return candidate;
5828
+ }
5829
+ }
5830
+
5831
+ return null;
5832
+ }
5833
+
5834
+ private static getRenderedWordSyllables(
5328
5835
  syllable: HTMLElement,
5329
- elapsedTimeMs = 0,
5836
+ ): HTMLElement[] {
5837
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
5838
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
5839
+ const wordSyllables = wordElements.flatMap(
5840
+ element =>
5841
+ Array.from(
5842
+ element.querySelectorAll('.lyrics-syllable'),
5843
+ ) as HTMLElement[],
5844
+ );
5845
+
5846
+ return wordSyllables.filter(
5847
+ wordSyllable => !wordSyllable.classList.contains('transliteration'),
5848
+ );
5849
+ }
5850
+
5851
+ private static getWordElementForSyllable(
5852
+ syllable: HTMLElement,
5853
+ ): HTMLElement | undefined {
5854
+ return syllable.parentElement?.parentElement as HTMLElement | undefined;
5855
+ }
5856
+
5857
+ private static getWordPreWipeKey(syllable: HTMLElement): string {
5858
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
5859
+ return (
5860
+ wordElement?.dataset.virtualWordId ||
5861
+ `${syllable.getAttribute('data-start-time') || ''}:${AmLyrics.getSyllableWordIndex(
5862
+ syllable,
5863
+ )}`
5864
+ );
5865
+ }
5866
+
5867
+ private static isPreWipeArmed(syllable: HTMLElement): boolean {
5868
+ const wordElement = AmLyrics.getWordElementForSyllable(syllable);
5869
+ const target = wordElement as any;
5870
+ return Boolean(
5871
+ target?._wordPreWipeKey === AmLyrics.getWordPreWipeKey(syllable),
5872
+ );
5873
+ }
5874
+
5875
+ private static applyWordPreWipe(
5876
+ nextSyllable: HTMLElement,
5877
+ wordSyllables: HTMLElement[],
5878
+ currentTimeMs: number,
5879
+ preWipeStartMs: number,
5880
+ preWipeDurationMs: number,
5330
5881
  ): void {
5331
- if (syllable.classList.contains('highlight')) return;
5882
+ if (AmLyrics.isPreWipeArmed(nextSyllable)) return;
5883
+
5884
+ const wordElement = AmLyrics.getWordElementForSyllable(nextSyllable);
5885
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
5886
+ const charSpans = AmLyrics.getCachedVirtualWordCharSpans(wordElement, []);
5887
+ const elapsedPreWipe = currentTimeMs - preWipeStartMs;
5888
+ const charCount =
5889
+ charSpans.length ||
5890
+ wordSyllables.reduce(
5891
+ (total, wordSyllable) =>
5892
+ total + AmLyrics.getVisibleCharacterCount(wordSyllable),
5893
+ 0,
5894
+ ) ||
5895
+ AmLyrics.getVisibleCharacterCount(nextSyllable);
5896
+ AmLyrics.ensureWordWipeGeometry(charSpans, charCount);
5897
+ const leadChar = charSpans[0];
5898
+ const leadCharSyllable = leadChar?.closest(
5899
+ '.lyrics-syllable',
5900
+ ) as HTMLElement | null;
5901
+ const preWipeSyllable =
5902
+ leadCharSyllable || wordSyllables[0] || nextSyllable;
5332
5903
 
5333
- const { classList } = syllable;
5334
- const isRTL = classList.contains('rtl-text');
5335
- const charSpans = Array.from(
5336
- syllable.querySelectorAll('span.char'),
5337
- ) as HTMLElement[];
5338
- const wordElement = syllable.parentElement?.parentElement; // syllable-wrap -> word
5339
- const virtualWordId = (wordElement as HTMLElement | undefined)?.dataset
5340
- .virtualWordId;
5341
- let wordElements: HTMLElement[] = [];
5342
- if (virtualWordId && wordElement?.parentElement) {
5904
+ AmLyrics.applyWipeShape(preWipeSyllable, charCount);
5905
+ preWipeSyllable.style.setProperty(
5906
+ '--pre-wipe-duration',
5907
+ `${preWipeDurationMs}ms`,
5908
+ );
5909
+ preWipeSyllable.style.setProperty(
5910
+ '--pre-wipe-delay',
5911
+ `${-elapsedPreWipe}ms`,
5912
+ );
5913
+ preWipeSyllable.classList.add('pre-highlight');
5914
+
5915
+ // Character-animated words still need a single word-leading gradient.
5916
+ // Giving every glyph this state creates one gradient per character and
5917
+ // makes the whole word enter the pre-wipe continuation simultaneously.
5918
+ if (leadChar) {
5919
+ AmLyrics.applyWipeShape(leadChar, charCount);
5920
+ leadChar.style.setProperty(
5921
+ '--pre-wipe-duration',
5922
+ `${preWipeDurationMs}ms`,
5923
+ );
5924
+ leadChar.style.setProperty('--pre-wipe-delay', `${-elapsedPreWipe}ms`);
5925
+ leadChar.classList.add('pre-wipe-lead');
5926
+ }
5927
+
5928
+ wordElements.forEach(element => {
5929
+ const target = element as any;
5930
+ target._wordPreWipeKey = AmLyrics.getWordPreWipeKey(nextSyllable);
5931
+ });
5932
+ }
5933
+
5934
+ private static maybePreWipeNextWord(
5935
+ syllables: HTMLElement[],
5936
+ index: number,
5937
+ currentTimeMs: number,
5938
+ currentEndTimeMs: number,
5939
+ ): void {
5940
+ const syllable = syllables[index];
5941
+ if (
5942
+ syllable.classList.contains('line-synced') ||
5943
+ syllable.classList.contains('transliteration') ||
5944
+ syllable.closest('.lyrics-gap')
5945
+ ) {
5946
+ return;
5947
+ }
5948
+
5949
+ const currentWordReady =
5950
+ syllable.classList.contains('finished') ||
5951
+ currentTimeMs >= currentEndTimeMs - WORD_PRE_WIPE_HANDOFF_LEAD_MS;
5952
+ if (!currentWordReady) {
5953
+ return;
5954
+ }
5955
+
5956
+ const nextSyllable = AmLyrics.getNextWordSyllable(syllables, index);
5957
+ if (
5958
+ !nextSyllable ||
5959
+ nextSyllable.classList.contains('line-synced') ||
5960
+ nextSyllable.classList.contains('transliteration') ||
5961
+ nextSyllable.closest('.lyrics-gap') ||
5962
+ nextSyllable.classList.contains('highlight') ||
5963
+ nextSyllable.classList.contains('finished')
5964
+ ) {
5965
+ return;
5966
+ }
5967
+
5968
+ const nextStartTimeMs = (nextSyllable as any)._cachedStartTime;
5969
+ if (!Number.isFinite(nextStartTimeMs)) return;
5970
+
5971
+ const gapMs = nextStartTimeMs - currentEndTimeMs;
5972
+ if (gapMs > NEXT_WORD_PRE_WIPE_MAX_GAP_MS || gapMs < -50) {
5973
+ return;
5974
+ }
5975
+
5976
+ const nextWordSyllables = AmLyrics.getRenderedWordSyllables(nextSyllable);
5977
+ const preWipeSyllables =
5978
+ nextWordSyllables.length > 0 ? nextWordSyllables : [nextSyllable];
5979
+ const wordElement = AmLyrics.getWordElementForSyllable(nextSyllable);
5980
+ const wordCharSpans = AmLyrics.getCachedVirtualWordCharSpans(
5981
+ wordElement,
5982
+ [],
5983
+ );
5984
+ const charCount =
5985
+ wordCharSpans.length ||
5986
+ preWipeSyllables.reduce(
5987
+ (total, wordSyllable) =>
5988
+ total + AmLyrics.getVisibleCharacterCount(wordSyllable),
5989
+ 0,
5990
+ );
5991
+ if (charCount <= 0) return;
5992
+
5993
+ const preWipeDuration = AmLyrics.clamp(
5994
+ 64 + charCount * 9,
5995
+ NEXT_WORD_PRE_WIPE_MIN_DURATION_MS,
5996
+ NEXT_WORD_PRE_WIPE_MAX_DURATION_MS,
5997
+ );
5998
+ const preWipeStart = Math.max(
5999
+ nextStartTimeMs - preWipeDuration,
6000
+ currentEndTimeMs - WORD_PRE_WIPE_HANDOFF_LEAD_MS,
6001
+ );
6002
+
6003
+ if (currentTimeMs < preWipeStart || currentTimeMs >= nextStartTimeMs) {
6004
+ return;
6005
+ }
6006
+
6007
+ AmLyrics.applyWordPreWipe(
6008
+ nextSyllable,
6009
+ preWipeSyllables,
6010
+ currentTimeMs,
6011
+ preWipeStart,
6012
+ preWipeDuration,
6013
+ );
6014
+ }
6015
+
6016
+ private static getCachedCharSpans(element: HTMLElement): HTMLElement[] {
6017
+ const cacheTarget = element as any;
6018
+ if (!cacheTarget._cachedCharSpans) {
6019
+ cacheTarget._cachedCharSpans = Array.from(
6020
+ element.querySelectorAll('span.char'),
6021
+ ) as HTMLElement[];
6022
+ }
6023
+ return cacheTarget._cachedCharSpans as HTMLElement[];
6024
+ }
6025
+
6026
+ private static getCachedVirtualWordElements(
6027
+ wordElement: HTMLElement | undefined,
6028
+ ): HTMLElement[] {
6029
+ if (!wordElement) return [];
6030
+
6031
+ const cacheTarget = wordElement as any;
6032
+ if (cacheTarget._cachedVirtualWordElements) {
6033
+ return cacheTarget._cachedVirtualWordElements as HTMLElement[];
6034
+ }
6035
+
6036
+ const { virtualWordId } = wordElement.dataset;
6037
+ let wordElements: HTMLElement[] = [wordElement];
6038
+ if (virtualWordId && wordElement.parentElement) {
5343
6039
  wordElements = Array.from(
5344
6040
  wordElement.parentElement.querySelectorAll('.lyrics-word'),
5345
6041
  ).filter(
5346
6042
  el => (el as HTMLElement).dataset.virtualWordId === virtualWordId,
5347
6043
  ) as HTMLElement[];
5348
- } else if (wordElement) {
5349
- wordElements = [wordElement as HTMLElement];
5350
6044
  }
5351
- const allWordCharSpans = wordElements.flatMap(
6045
+
6046
+ wordElements.forEach(element => {
6047
+ const target = element as any;
6048
+ target._cachedVirtualWordElements = wordElements;
6049
+ });
6050
+
6051
+ return wordElements;
6052
+ }
6053
+
6054
+ private static getCachedVirtualWordCharSpans(
6055
+ wordElement: HTMLElement | undefined,
6056
+ fallbackCharSpans: HTMLElement[],
6057
+ ): HTMLElement[] {
6058
+ if (!wordElement) return fallbackCharSpans;
6059
+
6060
+ const cacheTarget = wordElement as any;
6061
+ if (cacheTarget._cachedVirtualWordCharSpans) {
6062
+ return cacheTarget._cachedVirtualWordCharSpans as HTMLElement[];
6063
+ }
6064
+
6065
+ const wordElements = AmLyrics.getCachedVirtualWordElements(wordElement);
6066
+ const charSpans = wordElements.flatMap(
5352
6067
  word => Array.from(word.querySelectorAll('span.char')) as HTMLElement[],
5353
6068
  );
5354
- const isGrowable = wordElement?.classList.contains('growable');
5355
- const isCharRise = wordElement?.classList.contains('char-rise');
6069
+ const result = charSpans.length > 0 ? charSpans : fallbackCharSpans;
6070
+
6071
+ wordElements.forEach(element => {
6072
+ const target = element as any;
6073
+ target._cachedVirtualWordCharSpans = result;
6074
+ });
6075
+
6076
+ return result;
6077
+ }
6078
+
6079
+ private static updateSyllableAnimation(
6080
+ syllable: HTMLElement,
6081
+ elapsedTimeMs = 0,
6082
+ ): void {
6083
+ if (syllable.classList.contains('highlight')) return;
6084
+
6085
+ const { classList } = syllable;
6086
+ const hadPreHighlight = classList.contains('pre-highlight');
6087
+ const isRTL = classList.contains('rtl-text');
6088
+ const charSpans = AmLyrics.getCachedCharSpans(syllable);
6089
+ const wordElement = syllable.parentElement?.parentElement; // syllable-wrap -> word
6090
+ const typedWordElement = wordElement as HTMLElement | undefined;
6091
+ const allWordElements =
6092
+ AmLyrics.getCachedVirtualWordElements(typedWordElement);
6093
+ const allWordCharSpans = AmLyrics.getCachedVirtualWordCharSpans(
6094
+ typedWordElement,
6095
+ charSpans,
6096
+ );
6097
+ const isGrowable = typedWordElement?.classList.contains('growable');
6098
+ const isCharRise = typedWordElement?.classList.contains('char-rise');
6099
+ const isCharDrag = typedWordElement?.classList.contains('char-drag');
5356
6100
  const isFirstSyllable =
5357
6101
  syllable.getAttribute('data-syllable-index') === '0';
5358
6102
  const syllableStartMs = parseFloat(
5359
6103
  syllable.getAttribute('data-start-time') || '0',
5360
6104
  );
5361
6105
  const virtualWordStartMs = parseFloat(
5362
- (wordElement as HTMLElement | undefined)?.dataset.virtualWordStart || '',
6106
+ typedWordElement?.dataset.virtualWordStart || '',
5363
6107
  );
5364
6108
  const isFirstInVirtualWord =
5365
6109
  isFirstSyllable &&
@@ -5377,8 +6121,11 @@ export class AmLyrics extends LitElement {
5377
6121
  syllable.getAttribute('data-duration') ||
5378
6122
  '0',
5379
6123
  ) || syllableDurationMs;
6124
+ const wordElapsedTimeMs = Number.isFinite(virtualWordStartMs)
6125
+ ? elapsedTimeMs + (syllableStartMs - virtualWordStartMs)
6126
+ : elapsedTimeMs;
6127
+ const charWipeDurationMs = Math.max(wordDurationMs, syllableDurationMs);
5380
6128
 
5381
- // Use a Map to collect animations like YouLyPlus
5382
6129
  const charAnimationsMap = new Map<HTMLElement, string>();
5383
6130
  const styleUpdates: Array<{
5384
6131
  element: HTMLElement;
@@ -5446,21 +6193,81 @@ export class AmLyrics extends LitElement {
5446
6193
  });
5447
6194
  }
5448
6195
 
6196
+ if (isCharDrag && isFirstInVirtualWord && allWordCharSpans.length > 0) {
6197
+ const finalDuration = Math.max(wordDurationMs, syllableDurationMs);
6198
+ const baseDelayPerChar = AmLyrics.clamp(finalDuration * 0.15, 64, 118);
6199
+ const dragDurationMs = AmLyrics.clamp(finalDuration * 0.82, 560, 900);
6200
+
6201
+ allWordCharSpans.forEach(span => {
6202
+ const charIndex = parseFloat(span.dataset.syllableCharIndex || '0');
6203
+ const dragDelay = baseDelayPerChar * charIndex;
6204
+
6205
+ charAnimationsMap.set(
6206
+ span,
6207
+ `drag-char ${dragDurationMs}ms ease ${dragDelay}ms forwards`,
6208
+ );
6209
+ });
6210
+ }
6211
+
5449
6212
  // Step 2: Wipe Pass
5450
6213
  if (charSpans.length > 0) {
5451
- charSpans.forEach((span, charIndex) => {
6214
+ const wipeCharCount =
6215
+ allWordCharSpans.length ||
6216
+ charSpans.length ||
6217
+ AmLyrics.getVisibleCharacterCount(syllable);
6218
+ const wipeScale = AmLyrics.getLongWordWipeScale(wipeCharCount);
6219
+ AmLyrics.applyWipeShape(syllable, wipeCharCount);
6220
+ AmLyrics.ensureWordWipeGeometry(allWordCharSpans, wipeCharCount);
6221
+ allWordCharSpans.forEach(span =>
6222
+ AmLyrics.applyWipeShape(span, wipeCharCount),
6223
+ );
6224
+
6225
+ const hasWordLevelWipe =
6226
+ !isFirstInVirtualWord &&
6227
+ (Boolean((typedWordElement as any)?._wordWipeStarted) ||
6228
+ allWordCharSpans.some(span => span.style.animation.includes('wipe')));
6229
+ let charSpansToAnimate = charSpans;
6230
+ if (isFirstInVirtualWord) {
6231
+ charSpansToAnimate = allWordCharSpans;
6232
+ } else if (hasWordLevelWipe) {
6233
+ charSpansToAnimate = [];
6234
+ }
6235
+
6236
+ if (charSpansToAnimate.length > 0 && allWordElements.length > 0) {
6237
+ allWordElements.forEach(element => {
6238
+ const target = element as any;
6239
+ target._wordWipeStarted = true;
6240
+ target._wordPreWipeKey = undefined;
6241
+ });
6242
+ }
6243
+
6244
+ charSpansToAnimate.forEach((span, charIndex) => {
5452
6245
  const startPct = parseFloat(span.dataset.wipeStart || '0');
5453
6246
  const durationPct = parseFloat(span.dataset.wipeDuration || '0');
6247
+ const globalCharIndex = parseFloat(
6248
+ span.dataset.syllableCharIndex || `${charIndex}`,
6249
+ );
5454
6250
 
5455
- const wipeDelay = syllableDurationMs * startPct - elapsedTimeMs;
5456
- const wipeDuration = syllableDurationMs * durationPct;
5457
-
5458
- const useStartAnimation = isFirstInContainer && charIndex === 0;
5459
- let charWipeAnimation = 'wipe';
5460
- if (useStartAnimation) {
5461
- charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
5462
- } else {
5463
- charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
6251
+ const hadCharPreWipe =
6252
+ span.classList.contains('pre-wipe-lead') ||
6253
+ (hadPreHighlight && globalCharIndex === 0);
6254
+ const charStartMs = charWipeDurationMs * startPct;
6255
+ const remainingWordWipeMs = Math.max(
6256
+ 0,
6257
+ charWipeDurationMs - charStartMs,
6258
+ );
6259
+ const wipeDelay = charStartMs - wordElapsedTimeMs;
6260
+ const wipeDuration = Math.min(
6261
+ charWipeDurationMs * durationPct * wipeScale,
6262
+ remainingWordWipeMs,
6263
+ );
6264
+ const useStartAnimation =
6265
+ isFirstInContainer && globalCharIndex === 0 && !hadCharPreWipe;
6266
+ let charWipeAnimation = 'char-wipe';
6267
+ if (hadCharPreWipe) {
6268
+ charWipeAnimation = 'char-wipe';
6269
+ } else if (useStartAnimation) {
6270
+ charWipeAnimation = 'char-start-wipe';
5464
6271
  }
5465
6272
 
5466
6273
  const existingAnimation =
@@ -5471,36 +6278,37 @@ export class AmLyrics extends LitElement {
5471
6278
  if (
5472
6279
  existingAnimation &&
5473
6280
  (existingAnimation.includes('grow-dynamic') ||
5474
- existingAnimation.includes('rise-char'))
6281
+ existingAnimation.includes('rise-char') ||
6282
+ existingAnimation.includes('drag-char'))
5475
6283
  ) {
5476
6284
  animationParts.push(existingAnimation.split(',')[0].trim());
5477
6285
  }
5478
- if (charIndex > 0 && wipeDelay > 0 && wipeDuration > 0) {
5479
- const arrivalTime =
5480
- (span.dataset.preWipeArrival
5481
- ? parseFloat(span.dataset.preWipeArrival)
5482
- : syllableDurationMs * startPct) - elapsedTimeMs;
5483
- const measuredPreWipeDuration = parseFloat(
6286
+ if (
6287
+ globalCharIndex > 0 &&
6288
+ !hadCharPreWipe &&
6289
+ wipeDelay > 0 &&
6290
+ wipeDuration > 0
6291
+ ) {
6292
+ const measuredDuration = Number.parseFloat(
5484
6293
  span.dataset.preWipeDuration || '100',
5485
6294
  );
5486
6295
  const preWipeDuration = Math.min(
5487
- measuredPreWipeDuration,
6296
+ measuredDuration,
5488
6297
  wipeDuration * 0.9,
5489
- syllableDurationMs * 0.08,
5490
- arrivalTime,
6298
+ charWipeDurationMs * 0.08,
6299
+ wipeDelay,
5491
6300
  );
5492
- const animDelay = arrivalTime - preWipeDuration;
5493
6301
 
5494
6302
  if (preWipeDuration >= 16) {
5495
6303
  animationParts.push(
5496
- `pre-wipe-char ${preWipeDuration}ms linear ${animDelay}ms none`,
6304
+ `char-pre-wipe ${preWipeDuration}ms linear ${wipeDelay - preWipeDuration}ms none`,
5497
6305
  );
5498
6306
  }
5499
6307
  }
5500
-
5501
6308
  if (wipeDuration > 0) {
6309
+ const wipeFillMode = hadCharPreWipe ? 'both' : 'forwards';
5502
6310
  animationParts.push(
5503
- `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`,
6311
+ `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms ${wipeFillMode}`,
5504
6312
  );
5505
6313
  }
5506
6314
 
@@ -5513,10 +6321,15 @@ export class AmLyrics extends LitElement {
5513
6321
  const wipeRatio = parseFloat(
5514
6322
  syllable.getAttribute('data-wipe-ratio') || '1',
5515
6323
  );
5516
- const visualDuration = syllableDurationMs * wipeRatio;
6324
+ const wipeCharCount = AmLyrics.getVisibleCharacterCount(syllable);
6325
+ const wipeScale = AmLyrics.getLongWordWipeScale(wipeCharCount);
6326
+ const visualDuration = syllableDurationMs * wipeRatio * wipeScale;
6327
+ AmLyrics.applyWipeShape(syllable, wipeCharCount);
5517
6328
 
5518
6329
  let wipeAnimation = 'wipe';
5519
- if (isFirstInContainer) {
6330
+ if (hadPreHighlight) {
6331
+ wipeAnimation = isRTL ? 'wipe-from-pre-rtl' : 'wipe-from-pre';
6332
+ } else if (isFirstInContainer) {
5520
6333
  wipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
5521
6334
  } else {
5522
6335
  wipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
@@ -5530,18 +6343,28 @@ export class AmLyrics extends LitElement {
5530
6343
  }
5531
6344
 
5532
6345
  // --- WRITE PHASE ---
6346
+ if (allWordElements.length > 0) {
6347
+ allWordElements.forEach(element => {
6348
+ const target = element as any;
6349
+ target._wordPreWipeKey = undefined;
6350
+ });
6351
+ }
6352
+
5533
6353
  classList.remove('pre-highlight');
5534
6354
  classList.add('highlight');
6355
+ allWordCharSpans.forEach(span => AmLyrics.clearPreWipeLead(span));
6356
+
6357
+ // Apply keyframe variables before assigning animation strings so the
6358
+ // first painted frame never uses fallback transform values.
6359
+ for (const update of styleUpdates) {
6360
+ update.element.style.setProperty(update.property, update.value);
6361
+ }
5535
6362
 
5536
6363
  for (const [span, animationString] of charAnimationsMap.entries()) {
5537
6364
  span.style.willChange = 'transform';
6365
+ span.style.removeProperty('background-color');
5538
6366
  span.style.animation = animationString;
5539
6367
  }
5540
-
5541
- // Apply style updates
5542
- for (const update of styleUpdates) {
5543
- update.element.style.setProperty(update.property, update.value);
5544
- }
5545
6368
  }
5546
6369
 
5547
6370
  /**
@@ -5566,6 +6389,7 @@ export class AmLyrics extends LitElement {
5566
6389
  el.style.animation = '';
5567
6390
  el.style.transition = 'none';
5568
6391
  el.style.backgroundColor = 'var(--lyplus-text-secondary)';
6392
+ AmLyrics.clearPreWipeLead(el);
5569
6393
  }
5570
6394
 
5571
6395
  // Immediately remove all state classes
@@ -5577,12 +6401,22 @@ export class AmLyrics extends LitElement {
5577
6401
  );
5578
6402
  }
5579
6403
 
6404
+ private static resetWordAnimationState(line: HTMLElement): void {
6405
+ const wordElements = line.querySelectorAll('.lyrics-word');
6406
+ wordElements.forEach(wordElement => {
6407
+ const target = wordElement as any;
6408
+ target._wordPreWipeKey = undefined;
6409
+ target._wordWipeStarted = false;
6410
+ });
6411
+ }
6412
+
5580
6413
  /**
5581
6414
  * Reset all syllables in a line — batches deferred cleanup into a single rAF
5582
6415
  */
5583
6416
  private static resetSyllables(line: HTMLElement): void {
5584
6417
  if (!line) return;
5585
6418
  line.classList.remove('persist-highlight');
6419
+ AmLyrics.resetWordAnimationState(line);
5586
6420
  // eslint-disable-next-line no-param-reassign
5587
6421
  (line as any)._cachedSyllableElements = null;
5588
6422
  const syllables = line.getElementsByClassName('lyrics-syllable');
@@ -5614,6 +6448,7 @@ export class AmLyrics extends LitElement {
5614
6448
  private static unfinishSyllables(line: HTMLElement): void {
5615
6449
  if (!line) return;
5616
6450
  line.classList.remove('persist-highlight');
6451
+ AmLyrics.resetWordAnimationState(line);
5617
6452
  const syllables = line.getElementsByClassName('lyrics-syllable');
5618
6453
  for (let i = 0; i < syllables.length; i += 1) {
5619
6454
  const s = syllables[i] as HTMLElement;
@@ -5631,6 +6466,7 @@ export class AmLyrics extends LitElement {
5631
6466
  el.style.removeProperty('background-color');
5632
6467
  el.style.removeProperty('transition');
5633
6468
  el.style.removeProperty('filter');
6469
+ AmLyrics.clearPreWipeLead(el);
5634
6470
  }
5635
6471
  }
5636
6472
  }
@@ -5681,22 +6517,33 @@ export class AmLyrics extends LitElement {
5681
6517
  syllable.style.animation = '';
5682
6518
  syllable.style.removeProperty('--pre-wipe-duration');
5683
6519
  syllable.style.removeProperty('--pre-wipe-delay');
6520
+ syllable.style.removeProperty('background-color');
6521
+ AmLyrics.applyWipeShape(
6522
+ syllable,
6523
+ AmLyrics.getVisibleCharacterCount(syllable),
6524
+ );
5684
6525
  const chars = syllable.querySelectorAll('span.char');
5685
6526
  for (let ci = 0; ci < chars.length; ci += 1) {
5686
6527
  const charEl = chars[ci] as HTMLElement;
5687
6528
  const currentAnim = charEl.style.animation || '';
5688
6529
  if (
5689
6530
  currentAnim.includes('grow-dynamic') ||
5690
- currentAnim.includes('rise-char')
6531
+ currentAnim.includes('rise-char') ||
6532
+ currentAnim.includes('drag-char')
5691
6533
  ) {
5692
6534
  const parts = currentAnim.split(',').map(p => p.trim());
5693
6535
  const transformAnim = parts.find(
5694
- p => p.includes('grow-dynamic') || p.includes('rise-char'),
6536
+ p =>
6537
+ p.includes('grow-dynamic') ||
6538
+ p.includes('rise-char') ||
6539
+ p.includes('drag-char'),
5695
6540
  );
5696
6541
  charEl.style.animation = transformAnim || '';
5697
6542
  } else {
5698
6543
  charEl.style.animation = '';
5699
6544
  }
6545
+ charEl.style.backgroundColor = 'var(--lyplus-text-primary)';
6546
+ AmLyrics.clearPreWipeLead(charEl);
5700
6547
  }
5701
6548
  }
5702
6549
  }
@@ -5751,14 +6598,20 @@ export class AmLyrics extends LitElement {
5751
6598
  if (!(currentTimeMs < startTime - 1000 && !hasActiveState)) {
5752
6599
  let preHighlightReset = false;
5753
6600
 
5754
- // Pre-highlight reset logic
5755
- if (hasPreHighlight && i > 0) {
5756
- const prevSyllable = syllables[i - 1];
5757
- if (!prevSyllable.classList.contains('highlight')) {
5758
- classList.remove('pre-highlight');
5759
- syllable.style.removeProperty('--pre-wipe-duration');
5760
- syllable.style.removeProperty('--pre-wipe-delay');
5761
- syllable.style.animation = '';
6601
+ // Before the syllable starts, pre-highlight only belongs beside a
6602
+ // previous active word. Once the syllable starts, updateSyllableAnimation
6603
+ // consumes the class so the actual wipe can continue from the pre-wipe
6604
+ // pose instead of restarting from the beginning.
6605
+ if (hasPreHighlight && currentTimeMs < startTime) {
6606
+ const prevSyllable = AmLyrics.getPreviousNonTransliterationSyllable(
6607
+ syllables,
6608
+ i,
6609
+ );
6610
+ const previousCarriesHighlight =
6611
+ prevSyllable?.classList.contains('highlight') ||
6612
+ prevSyllable?.classList.contains('finished');
6613
+ if (!previousCarriesHighlight) {
6614
+ AmLyrics.clearPreHighlight(syllable);
5762
6615
  preHighlightReset = true;
5763
6616
  }
5764
6617
  }
@@ -5791,6 +6644,8 @@ export class AmLyrics extends LitElement {
5791
6644
  // Not yet started
5792
6645
  AmLyrics.resetSyllable(syllable);
5793
6646
  }
6647
+
6648
+ AmLyrics.maybePreWipeNextWord(syllables, i, currentTimeMs, endTime);
5794
6649
  }
5795
6650
  }
5796
6651
  }
@@ -6151,6 +7006,9 @@ export class AmLyrics extends LitElement {
6151
7006
  data-end-time="${endTimeMs}"
6152
7007
  data-duration="${durationMs}"
6153
7008
  data-syllable-index="${syllableIndex}"
7009
+ data-word-index="${syllableIndex}"
7010
+ data-word-length="${syllable.text.replace(/\s/g, '')
7011
+ .length}"
6154
7012
  data-wipe-ratio="1"
6155
7013
  >${syllable.text}</span
6156
7014
  >${bgRomanizedText}</span
@@ -6175,6 +7033,7 @@ export class AmLyrics extends LitElement {
6175
7033
  const groupGrowable = lineData?.groupGrowable ?? [];
6176
7034
  const groupGlowing = lineData?.groupGlowing ?? [];
6177
7035
  const groupCharRise = lineData?.groupCharRise ?? [];
7036
+ const groupCharDrag = lineData?.groupCharDrag ?? [];
6178
7037
  const vwFullText = lineData?.vwFullText ?? [];
6179
7038
  const vwFullDuration = lineData?.vwFullDuration ?? [];
6180
7039
  const vwCharOffset = lineData?.vwCharOffset ?? [];
@@ -6182,7 +7041,6 @@ export class AmLyrics extends LitElement {
6182
7041
  const vwEndMs = lineData?.vwEndMs ?? [];
6183
7042
  const lineIsRTL = lineData?.lineIsRTL ?? false;
6184
7043
 
6185
- // Create main vocals using YouLyPlus syllable structure
6186
7044
  const mainVocalElement = html`<p
6187
7045
  class="main-vocal-container ${lineIsRTL ? 'rtl-text' : ''}"
6188
7046
  >
@@ -6190,28 +7048,26 @@ export class AmLyrics extends LitElement {
6190
7048
  const isGrowable = groupGrowable[groupIdx];
6191
7049
  const isGlowing = groupGlowing[groupIdx];
6192
7050
  const isCharRise = groupCharRise[groupIdx];
6193
- const isAnimatedByChar = isGrowable || isCharRise;
7051
+ const isCharDrag = groupCharDrag[groupIdx];
7052
+ const isAnimatedByChar = isGrowable || isCharRise || isCharDrag;
6194
7053
  const groupLineSynced = group.some(s => s.lineSynced);
6195
7054
 
6196
7055
  const wordText = isAnimatedByChar ? vwFullText[groupIdx] : '';
6197
7056
  const wordDuration = isAnimatedByChar
6198
7057
  ? vwFullDuration[groupIdx]
6199
7058
  : 0;
6200
- const wordNumChars = wordText.length;
7059
+ const wordNumChars = wordText.replace(/\s/g, '').length;
6201
7060
  const groupCharOffset = isAnimatedByChar
6202
7061
  ? vwCharOffset[groupIdx]
6203
7062
  : 0;
6204
- const virtualWordId = isAnimatedByChar
6205
- ? `${lineIndex}:${vwStartMs[groupIdx]}:${vwEndMs[groupIdx]}`
6206
- : '';
6207
- const virtualWordStart = isAnimatedByChar
6208
- ? vwStartMs[groupIdx]
6209
- : '';
6210
- const virtualWordEnd = isAnimatedByChar ? vwEndMs[groupIdx] : '';
7063
+ const virtualWordId = `${lineIndex}:${vwStartMs[groupIdx]}:${vwEndMs[groupIdx]}`;
7064
+ const virtualWordStart = vwStartMs[groupIdx];
7065
+ const virtualWordEnd = vwEndMs[groupIdx];
6211
7066
 
6212
7067
  let sylCharAccumulator = 0;
6213
7068
 
6214
7069
  const groupText = group.map(s => s.text).join('');
7070
+ const visibleWordLength = groupText.replace(/\s/g, '').length;
6215
7071
  const shouldAllowBreak =
6216
7072
  groupText.trim().length >= 16 ||
6217
7073
  /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(
@@ -6231,9 +7087,9 @@ export class AmLyrics extends LitElement {
6231
7087
  return html`<span
6232
7088
  class="lyrics-word${isGrowable ? ' growable' : ''}${isCharRise
6233
7089
  ? ' char-rise'
6234
- : ''}${isGlowing ? ' glowing' : ''}${shouldAllowBreak
6235
- ? ' allow-break'
6236
- : ''}"
7090
+ : ''}${isCharDrag ? ' char-drag' : ''}${isGlowing
7091
+ ? ' glowing'
7092
+ : ''}${shouldAllowBreak ? ' allow-break' : ''}"
6237
7093
  data-virtual-word-id="${virtualWordId}"
6238
7094
  data-virtual-word-start="${virtualWordStart}"
6239
7095
  data-virtual-word-end="${virtualWordEnd}"
@@ -6264,17 +7120,39 @@ export class AmLyrics extends LitElement {
6264
7120
  let syllableContent: any = text;
6265
7121
 
6266
7122
  if (isAnimatedByChar) {
6267
- let charIndexInsideSyllable = 0;
6268
7123
  const numCharsInSyllable =
6269
7124
  text.replace(/\s/g, '').length || 1;
7125
+ const hasVirtualTiming =
7126
+ wordDuration > 0 && Number.isFinite(virtualWordStart);
7127
+ const syllableStartRatio = hasVirtualTiming
7128
+ ? AmLyrics.clamp(
7129
+ (startTimeMs - virtualWordStart) / wordDuration,
7130
+ 0,
7131
+ 1,
7132
+ )
7133
+ : 0;
7134
+ const syllableDurationRatio = hasVirtualTiming
7135
+ ? AmLyrics.clamp(durationMs / wordDuration, 0, 1)
7136
+ : 1;
7137
+ let charIndexInsideSyllable = 0;
6270
7138
 
6271
7139
  syllableContent = html`${text.split('').map(char => {
6272
7140
  if (char === ' ') return ' ';
6273
7141
 
6274
7142
  const charIndexInsideWord =
6275
7143
  groupCharOffset + sylCharAccumulator;
6276
- const charStartPercentVal =
6277
- charIndexInsideSyllable / numCharsInSyllable;
7144
+ const localCharIndex = charIndexInsideSyllable;
7145
+ const visibleWordChars = Math.max(1, wordNumChars);
7146
+ const charStartPercentVal = AmLyrics.clamp(
7147
+ syllableStartRatio +
7148
+ (localCharIndex / numCharsInSyllable) *
7149
+ syllableDurationRatio,
7150
+ 0,
7151
+ 1,
7152
+ );
7153
+ const charDurationPercentVal =
7154
+ syllableDurationRatio / numCharsInSyllable ||
7155
+ 1 / visibleWordChars;
6278
7156
 
6279
7157
  sylCharAccumulator += 1;
6280
7158
  charIndexInsideSyllable += 1;
@@ -6336,29 +7214,41 @@ export class AmLyrics extends LitElement {
6336
7214
  1,
6337
7215
  Math.max(0.3, effectiveDuration / 2000),
6338
7216
  );
6339
- const charTranslateYPeak =
7217
+ const baseTranslateYPeak =
6340
7218
  -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
6341
7219
 
6342
7220
  const position = (charIndexInsideWord + 0.5) / wordNumChars;
6343
7221
  const horizontalOffset =
6344
7222
  (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25);
7223
+ const isDragMotion = isCharDrag;
7224
+ let charTranslateYPeak = baseTranslateYPeak;
7225
+ if (isCharRise) {
7226
+ charTranslateYPeak = 0;
7227
+ } else if (isDragMotion) {
7228
+ charTranslateYPeak = -0.78;
7229
+ }
6345
7230
 
7231
+ let motionHorizontalOffset = horizontalOffset;
7232
+ if (isCharRise) {
7233
+ motionHorizontalOffset = 0;
7234
+ } else if (isDragMotion) {
7235
+ motionHorizontalOffset = 0;
7236
+ }
6346
7237
  return html`<span
6347
7238
  class="char"
6348
7239
  data-char-index="${charIndexInsideWord}"
6349
7240
  data-syllable-char-index="${charIndexInsideWord}"
6350
7241
  data-wipe-start="${charStartPercentVal.toFixed(4)}"
6351
- data-wipe-duration="${(1 / numCharsInSyllable).toFixed(
6352
- 4,
6353
- )}"
7242
+ data-wipe-duration="${charDurationPercentVal.toFixed(4)}"
6354
7243
  data-horizontal-offset="${horizontalOffset.toFixed(2)}"
6355
7244
  data-max-scale="${charMaxScale.toFixed(3)}"
6356
7245
  data-matrix-scale="${(charMaxScale * 0.98).toFixed(3)}"
6357
- data-char-offset-x="${(horizontalOffset * 0.98).toFixed(
6358
- 2,
6359
- )}"
7246
+ data-char-offset-x="${(
7247
+ motionHorizontalOffset * 0.98
7248
+ ).toFixed(2)}"
6360
7249
  data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
6361
7250
  data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
7251
+ style="--word-wipe-width: ${visibleWordChars}ch; --char-wipe-position: -${charIndexInsideWord}ch"
6362
7252
  >${char}</span
6363
7253
  >`;
6364
7254
  })}`;
@@ -6377,6 +7267,8 @@ export class AmLyrics extends LitElement {
6377
7267
  data-duration="${durationMs}"
6378
7268
  data-word-duration="${wordDuration}"
6379
7269
  data-syllable-index="${sylIdx}"
7270
+ data-word-index="${groupIdx}"
7271
+ data-word-length="${visibleWordLength}"
6380
7272
  data-wipe-ratio="1"
6381
7273
  >${syllableContent}</span
6382
7274
  >${romanizedText}</span
@@ -6617,13 +7509,14 @@ export class AmLyrics extends LitElement {
6617
7509
  <button
6618
7510
  class="download-button source-switch-btn"
6619
7511
  title="Switch Lyrics Source"
6620
- 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;"
6621
7512
  @click=${this.switchSource}
6622
7513
  ?disabled=${this.isFetchingAlternatives}
6623
7514
  >
6624
7515
  <svg
6625
- class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
6626
- style="margin-right: 4px;"
7516
+ class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up ${this
7517
+ .isFetchingAlternatives
7518
+ ? 'is-loading'
7519
+ : ''}"
6627
7520
  xmlns="http://www.w3.org/2000/svg"
6628
7521
  width="12"
6629
7522
  height="12"