@uimaxbai/am-lyrics 1.5.4 → 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.4';
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;
@@ -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 {
@@ -444,11 +455,22 @@ export class AmLyrics extends LitElement {
444
455
  white-space: nowrap;
445
456
  }
446
457
 
458
+ .lyrics-word.char-drag {
459
+ display: inline-block;
460
+ vertical-align: baseline;
461
+ white-space: nowrap;
462
+ }
463
+
447
464
  .lyrics-word.char-rise.allow-break {
448
465
  display: inline;
449
466
  white-space: normal;
450
467
  }
451
468
 
469
+ .lyrics-word.char-drag.allow-break {
470
+ display: inline;
471
+ white-space: normal;
472
+ }
473
+
452
474
  .lyrics-syllable-wrap {
453
475
  display: inline;
454
476
  }
@@ -503,44 +525,30 @@ export class AmLyrics extends LitElement {
503
525
  .lyrics-line.active:not(.lyrics-gap)
504
526
  .lyrics-syllable.pre-highlight.no-chars {
505
527
  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%;
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;
524
537
  }
525
538
 
526
539
  .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.highlight.rtl-text,
527
540
  .lyrics-line.active:not(.lyrics-gap)
528
541
  .lyrics-syllable.pre-highlight.rtl-text {
529
542
  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%;
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%;
544
552
  }
545
553
 
546
554
  /* Background vocals: muted gray wipe instead of white.
@@ -557,18 +565,13 @@ export class AmLyrics extends LitElement {
557
565
  .lyrics-line.pre-active
558
566
  .background-vocal-container
559
567
  .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
- );
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
+ );
572
575
  }
573
576
 
574
577
  .lyrics-line.active
@@ -583,17 +586,13 @@ export class AmLyrics extends LitElement {
583
586
  .lyrics-line.pre-active
584
587
  .background-vocal-container
585
588
  .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
- );
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
+ );
597
596
  }
598
597
 
599
598
  /* Non-growable words float up with a gentle curve */
@@ -617,6 +616,10 @@ export class AmLyrics extends LitElement {
617
616
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
618
617
  }
619
618
 
619
+ .lyrics-word.char-drag .lyrics-syllable.cleanup .char {
620
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
621
+ }
622
+
620
623
  .lyrics-line.persist-highlight
621
624
  .lyrics-word.growable
622
625
  .lyrics-syllable.finished
@@ -624,6 +627,10 @@ export class AmLyrics extends LitElement {
624
627
  .lyrics-line.persist-highlight
625
628
  .lyrics-word.char-rise
626
629
  .lyrics-syllable.finished
630
+ .char,
631
+ .lyrics-line.persist-highlight
632
+ .lyrics-word.char-drag
633
+ .lyrics-syllable.finished
627
634
  .char {
628
635
  transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
629
636
  }
@@ -734,70 +741,56 @@ export class AmLyrics extends LitElement {
734
741
  transform 0.7s ease;
735
742
  }
736
743
 
744
+ .lyrics-word.char-drag span.char {
745
+ transition: color 0.18s;
746
+ }
747
+
737
748
  /* Active char spans: structural only, wipe animation sets gradient */
738
749
  .lyrics-line.active .lyrics-syllable span.char {
739
750
  background-clip: text;
740
751
  -webkit-background-clip: text;
741
752
  background-repeat: no-repeat;
742
- background-image:
743
- linear-gradient(
744
- 90deg,
745
- #ffffff00 0%,
746
- var(--lyplus-text-primary, #fff) 50%,
747
- #0000 100%
748
- ),
749
- linear-gradient(
750
- 90deg,
751
- var(--lyplus-text-primary, #fff) 100%,
752
- #0000 100%
753
- );
754
- background-size:
755
- 0.5em 100%,
756
- 0% 100%;
757
- background-position:
758
- -0.5em 0%,
759
- -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;
760
762
  transition:
761
763
  transform 0.7s ease,
762
764
  color 0.18s;
763
765
  }
764
766
 
765
767
  .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%;
780
- }
781
-
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%;
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%;
801
794
  }
802
795
 
803
796
  /* ==========================================================================
@@ -874,13 +867,7 @@ export class AmLyrics extends LitElement {
874
867
  color: var(--lyplus-text-primary) !important;
875
868
  }
876
869
 
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 {
870
+ .lyrics-line.active .lyrics-syllable.line-synced span.char {
884
871
  background-image: none !important;
885
872
  background-color: var(--lyplus-text-primary) !important;
886
873
  transition: background-color 120ms ease-out !important;
@@ -1053,6 +1040,49 @@ export class AmLyrics extends LitElement {
1053
1040
  gap: 4px;
1054
1041
  }
1055
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
+
1056
1086
  .control-button {
1057
1087
  background: transparent;
1058
1088
  border: 1px solid rgba(255, 255, 255, 0.3);
@@ -1061,7 +1091,10 @@ export class AmLyrics extends LitElement {
1061
1091
  font-size: 0.8em;
1062
1092
  color: rgba(255, 255, 255, 0.6);
1063
1093
  cursor: pointer;
1064
- transition: all 0.2s;
1094
+ transition:
1095
+ color 0.2s,
1096
+ border-color 0.2s,
1097
+ background-color 0.2s;
1065
1098
  font-weight: normal;
1066
1099
  }
1067
1100
 
@@ -1199,137 +1232,125 @@ export class AmLyrics extends LitElement {
1199
1232
  KEYFRAME ANIMATIONS
1200
1233
  ========================================================================== */
1201
1234
 
1235
+ @keyframes source-switch-spin {
1236
+ to {
1237
+ transform: rotate(360deg);
1238
+ }
1239
+ }
1240
+
1202
1241
  /* Wipe animation for syllables */
1203
1242
  @keyframes wipe {
1204
1243
  from {
1205
- background-size:
1206
- 0.75em 100%,
1207
- 0% 100%;
1208
- background-position:
1209
- -0.375em 0%,
1210
- 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;
1211
1257
  }
1212
1258
  to {
1213
- background-size:
1214
- 0.75em 100%,
1215
- 100% 100%;
1216
- background-position:
1217
- calc(100% + 0.375em) 0%,
1218
- left;
1259
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1260
+ background-position: left;
1219
1261
  }
1220
1262
  }
1221
1263
 
1222
1264
  @keyframes start-wipe {
1223
1265
  0% {
1224
- background-size:
1225
- 0.75em 100%,
1226
- 0% 100%;
1227
- background-position:
1228
- -0.75em 0%,
1229
- -0.375em 0%;
1266
+ background-size: 0% 100%;
1267
+ background-position: left;
1230
1268
  }
1231
1269
  100% {
1232
- background-size:
1233
- 0.75em 100%,
1234
- 100% 100%;
1235
- background-position:
1236
- calc(100% + 0.375em) 0%,
1237
- left;
1270
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1271
+ background-position: left;
1238
1272
  }
1239
1273
  }
1240
1274
 
1241
1275
  @keyframes wipe-rtl {
1242
1276
  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%;
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%;
1249
1290
  }
1250
1291
  to {
1251
- background-size:
1252
- 0.75em 100%,
1253
- 100% 100%;
1254
- background-position:
1255
- -0.75em 0%,
1256
- right 0%;
1292
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1293
+ background-position: right 0%;
1257
1294
  }
1258
1295
  }
1259
1296
 
1260
1297
  @keyframes start-wipe-rtl {
1261
1298
  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%;
1299
+ background-size: 0% 100%;
1300
+ background-position: right 0%;
1268
1301
  }
1269
1302
  100% {
1270
- background-size:
1271
- 0.75em 100%,
1272
- 100% 100%;
1273
- background-position:
1274
- -0.75em 0%,
1275
- right 0%;
1303
+ background-size: calc(100% + var(--wipe-gradient-width, 0.75em)) 100%;
1304
+ background-position: right 0%;
1276
1305
  }
1277
1306
  }
1278
1307
 
1279
1308
  @keyframes pre-wipe-universal {
1280
1309
  from {
1281
- background-size:
1282
- 0.75em 100%,
1283
- 0% 100%;
1284
- background-position:
1285
- -0.75em 0%,
1286
- left;
1310
+ background-size: 0% 100%;
1311
+ background-position: left;
1287
1312
  }
1288
1313
  to {
1289
- background-size:
1290
- 0.75em 100%,
1291
- 0% 100%;
1292
- background-position:
1293
- -0.375em 0%,
1294
- left;
1314
+ background-size: var(--wipe-gradient-width, 0.75em) 100%;
1315
+ background-position: left;
1295
1316
  }
1296
1317
  }
1297
1318
 
1298
1319
  @keyframes pre-wipe-universal-rtl {
1299
1320
  from {
1300
- background-size:
1301
- 0.75em 100%,
1302
- 0% 100%;
1303
- background-position:
1304
- calc(100% + 0.75em) 0%,
1305
- 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%;
1306
1334
  }
1307
1335
  to {
1308
- background-size:
1309
- 0.75em 100%,
1310
- 0% 100%;
1311
- background-position:
1312
- calc(100% + 0.375em) 0%,
1313
- 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%;
1314
1339
  }
1315
1340
  }
1316
1341
 
1317
- @keyframes pre-wipe-char {
1342
+ @keyframes wipe-word-from-pre {
1318
1343
  from {
1319
- background-size:
1320
- 0.75em 100%,
1321
- 0% 100%;
1322
- background-position:
1323
- -0.75em 0%,
1324
- left;
1344
+ background-size: var(--pre-wipe-word-size, var(--wipe-gradient-width))
1345
+ 100%;
1346
+ background-position: var(--char-wipe-position, left) 0%;
1325
1347
  }
1326
1348
  to {
1327
- background-size:
1328
- 0.75em 100%,
1329
- 0% 100%;
1330
- background-position:
1331
- -0.375em 0%,
1332
- 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%;
1333
1354
  }
1334
1355
  }
1335
1356
 
@@ -1423,6 +1444,15 @@ export class AmLyrics extends LitElement {
1423
1444
  }
1424
1445
  }
1425
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
+
1426
1456
  @keyframes grow-static {
1427
1457
  0%,
1428
1458
  100% {
@@ -1668,6 +1698,7 @@ export class AmLyrics extends LitElement {
1668
1698
  this.preActiveLineElements = [];
1669
1699
  this.positionedLineElements = [];
1670
1700
  this.activeGapLineElements = [];
1701
+ this.clearBackgroundExpandedLine();
1671
1702
 
1672
1703
  // Stop all running animations and clear highlights immediately
1673
1704
  if (this.lyricsContainer) {
@@ -1740,10 +1771,7 @@ export class AmLyrics extends LitElement {
1740
1771
  (switchBtn as HTMLButtonElement).disabled = this.isFetchingAlternatives;
1741
1772
  }
1742
1773
  if (svgEl) {
1743
- svgEl.setAttribute(
1744
- 'style',
1745
- `margin-right: 4px; ${this.isFetchingAlternatives ? 'animation: spin 1s linear infinite;' : ''}`,
1746
- );
1774
+ svgEl.classList.toggle('is-loading', this.isFetchingAlternatives);
1747
1775
  }
1748
1776
  if (labelEl) {
1749
1777
  labelEl.textContent = this.isFetchingAlternatives
@@ -1806,6 +1834,7 @@ export class AmLyrics extends LitElement {
1806
1834
  groupGrowable: boolean[];
1807
1835
  groupGlowing: boolean[];
1808
1836
  groupCharRise: boolean[];
1837
+ groupCharDrag: boolean[];
1809
1838
  vwFullText: string[];
1810
1839
  vwFullDuration: number[];
1811
1840
  vwCharOffset: number[];
@@ -1821,6 +1850,8 @@ export class AmLyrics extends LitElement {
1821
1850
 
1822
1851
  private lastPrimaryActiveLine: HTMLElement | null = null;
1823
1852
 
1853
+ private backgroundExpandedLine: HTMLElement | null = null;
1854
+
1824
1855
  // Scroll animation state
1825
1856
  private scrollAnimationState: {
1826
1857
  isAnimating: boolean;
@@ -2076,6 +2107,7 @@ export class AmLyrics extends LitElement {
2076
2107
  this.preActiveLineElements = [];
2077
2108
  this.positionedLineElements = [];
2078
2109
  this.activeGapLineElements = [];
2110
+ this.clearBackgroundExpandedLine();
2079
2111
 
2080
2112
  if (this.lyricsContainer) {
2081
2113
  this.isProgrammaticScroll = true;
@@ -2140,20 +2172,26 @@ export class AmLyrics extends LitElement {
2140
2172
  return 30;
2141
2173
  }
2142
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
+
2143
2188
  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
- >();
2189
+ collectedSources: YouLyPlusLyricsResult[],
2190
+ ): YouLyPlusLyricsResult[] {
2191
+ const uniqueSourcesMap = new Map<string, YouLyPlusLyricsResult>();
2150
2192
 
2151
2193
  for (const source of collectedSources) {
2152
- const normalizedSource = source.source
2153
- .toLowerCase()
2154
- .includes('lyricsplus')
2155
- ? 'QQ'
2156
- : source.source;
2194
+ const normalizedSource = AmLyrics.getDisplaySourceLabel(source.source);
2157
2195
 
2158
2196
  if (!uniqueSourcesMap.has(normalizedSource)) {
2159
2197
  uniqueSourcesMap.set(normalizedSource, {
@@ -2170,9 +2208,63 @@ export class AmLyrics extends LitElement {
2170
2208
  );
2171
2209
  }
2172
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
+
2173
2262
  private async switchSource() {
2174
2263
  if (this.isFetchingAlternatives) return;
2175
2264
 
2265
+ const currentSourceLabel = this.lyricsSource;
2266
+ const currentLines = this.lyrics;
2267
+
2176
2268
  if (!this.hasFetchedAllProviders) {
2177
2269
  this.isFetchingAlternatives = true;
2178
2270
  this._updateFooter();
@@ -2249,11 +2341,13 @@ export class AmLyrics extends LitElement {
2249
2341
  ...this.availableSources,
2250
2342
  ...newSources,
2251
2343
  ]);
2252
- // Re-sync current index since sorting might shift elements
2253
- this.currentSourceIndex = this.availableSources.findIndex(
2254
- 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,
2255
2350
  );
2256
- if (this.currentSourceIndex === -1) this.currentSourceIndex = 0;
2257
2351
  }
2258
2352
  }
2259
2353
  } finally {
@@ -2264,15 +2358,20 @@ export class AmLyrics extends LitElement {
2264
2358
  }
2265
2359
 
2266
2360
  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;
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);
2274
2374
  }
2275
- await this.onLyricsLoaded();
2276
2375
  }
2277
2376
  }
2278
2377
 
@@ -3538,7 +3637,11 @@ export class AmLyrics extends LitElement {
3538
3637
  if (!newActiveLines.includes(lineIndex)) {
3539
3638
  const lineElement = this._getLineElement(lineIndex);
3540
3639
  if (lineElement) {
3541
- if (isSeek || this.isUserScrolling) {
3640
+ if (
3641
+ isSeek ||
3642
+ this.isUserScrolling ||
3643
+ AmLyrics.isLineSyncedLine(this.lyrics?.[lineIndex])
3644
+ ) {
3542
3645
  AmLyrics.unfinishSyllables(lineElement);
3543
3646
  } else {
3544
3647
  AmLyrics.finishSyllablesUpToTime(lineElement, newTime);
@@ -3555,12 +3658,13 @@ export class AmLyrics extends LitElement {
3555
3658
  }
3556
3659
  }
3557
3660
 
3558
- // 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.
3559
3663
  for (const lineIndex of newActiveLines) {
3560
3664
  if (!oldActiveLines.includes(lineIndex)) {
3561
3665
  const lineElement = this._getLineElement(lineIndex);
3562
3666
  if (lineElement) {
3563
- lineElement.classList.add('active', 'bg-expanded');
3667
+ lineElement.classList.add('active');
3564
3668
  lineElement.classList.remove('pre-active');
3565
3669
  const preIdx = this.preActiveLineElements.indexOf(lineElement);
3566
3670
  if (preIdx !== -1) this.preActiveLineElements.splice(preIdx, 1);
@@ -3824,8 +3928,14 @@ export class AmLyrics extends LitElement {
3824
3928
  const activeLines = this.findActiveLineIndices(this.currentTime);
3825
3929
  for (const lineIndex of activeLines) {
3826
3930
  const lineEl = this._getLineElement(lineIndex);
3827
- if (lineEl) lineEl.classList.add('active', 'bg-expanded');
3931
+ if (lineEl) lineEl.classList.add('active');
3828
3932
  }
3933
+ const primaryActiveLine = this.getPrimaryActiveLineIndex(activeLines);
3934
+ this.setBackgroundExpandedLine(
3935
+ primaryActiveLine !== null
3936
+ ? this._getLineElement(primaryActiveLine)
3937
+ : null,
3938
+ );
3829
3939
 
3830
3940
  // Trigger a faux time-change so that updateSyllablesForLine fires
3831
3941
  // to setup inline syllable CSS wipe animations for whatever the current time is
@@ -3872,6 +3982,7 @@ export class AmLyrics extends LitElement {
3872
3982
  this.preActiveLineElements = [];
3873
3983
  this.positionedLineElements = [];
3874
3984
  this.activeGapLineElements = [];
3985
+ this.clearBackgroundExpandedLine();
3875
3986
  this.setUserScrolling(false);
3876
3987
 
3877
3988
  // Cancel any running animations
@@ -3943,6 +4054,7 @@ export class AmLyrics extends LitElement {
3943
4054
  // Don't override it with a scroll back to the last lyric.
3944
4055
  const footer = this.lyricsContainer.querySelector('.lyrics-footer');
3945
4056
  if (footer?.classList.contains('active')) {
4057
+ this.setBackgroundExpandedLine(null);
3946
4058
  return;
3947
4059
  }
3948
4060
 
@@ -3994,10 +4106,17 @@ export class AmLyrics extends LitElement {
3994
4106
  targetElement = this._getLineElement(targetLineIdx);
3995
4107
  }
3996
4108
  }
3997
- 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);
3998
4117
 
3999
- // Unblur the upcoming target line early (pre-active) so background
4000
- // 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.
4001
4120
  if (!targetElement.classList.contains('active')) {
4002
4121
  targetElement.classList.add('pre-active');
4003
4122
  if (!this.preActiveLineElements.includes(targetElement)) {
@@ -4005,8 +4124,6 @@ export class AmLyrics extends LitElement {
4005
4124
  }
4006
4125
  }
4007
4126
 
4008
- const scrollDuration = scrollLookAheadMs;
4009
-
4010
4127
  this.focusLine(targetElement, forceScroll, scrollDuration);
4011
4128
  }
4012
4129
 
@@ -4096,6 +4213,7 @@ export class AmLyrics extends LitElement {
4096
4213
  this.preActiveLineElements = [];
4097
4214
  this.positionedLineElements = [];
4098
4215
  this.activeGapLineElements = [];
4216
+ this.clearBackgroundExpandedLine();
4099
4217
  this.visibilityObserver?.disconnect();
4100
4218
  this.visibilityObserver = undefined;
4101
4219
  }
@@ -4139,6 +4257,7 @@ export class AmLyrics extends LitElement {
4139
4257
  const groupGrowable: boolean[] = new Array(wordGroups.length).fill(false);
4140
4258
  const groupGlowing: boolean[] = new Array(wordGroups.length).fill(false);
4141
4259
  const groupCharRise: boolean[] = new Array(wordGroups.length).fill(false);
4260
+ const groupCharDrag: boolean[] = new Array(wordGroups.length).fill(false);
4142
4261
  const vwFullText: string[] = new Array(wordGroups.length).fill('');
4143
4262
  const vwFullDuration: number[] = new Array(wordGroups.length).fill(0);
4144
4263
  const vwCharOffset: number[] = new Array(wordGroups.length).fill(0);
@@ -4183,9 +4302,13 @@ export class AmLyrics extends LitElement {
4183
4302
  line.isWordSynced === false || line.text.some(s => s.lineSynced);
4184
4303
  let isGrowableVW = canAnimateByChar && wordLen > 0 && wordLen <= 7;
4185
4304
  if (isGrowableVW) {
4186
- if (wordLen < 3) {
4305
+ if (wordLen <= 1) {
4187
4306
  isGrowableVW =
4188
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;
4189
4312
  } else {
4190
4313
  isGrowableVW =
4191
4314
  combinedDuration >= 850 && combinedDuration >= wordLen * 190;
@@ -4194,6 +4317,11 @@ export class AmLyrics extends LitElement {
4194
4317
 
4195
4318
  const hasCharRiseDuration =
4196
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);
4197
4325
  const hasLongShortWordDuration =
4198
4326
  wordLen >= 4 && combinedDuration >= Math.max(1300, wordLen * 260);
4199
4327
  const isCharRiseVW =
@@ -4202,6 +4330,11 @@ export class AmLyrics extends LitElement {
4202
4330
  !isGrowableVW &&
4203
4331
  ((wordLen >= 8 && hasCharRiseDuration) ||
4204
4332
  (wordLen < 8 && hasLongShortWordDuration));
4333
+ const isCharDragVW =
4334
+ canAnimateByChar &&
4335
+ !isLineSynced &&
4336
+ !isGrowableVW &&
4337
+ hasTinyWordDragDuration;
4205
4338
 
4206
4339
  const isGlowingVW = isGrowableVW && !isLineSynced;
4207
4340
 
@@ -4210,6 +4343,7 @@ export class AmLyrics extends LitElement {
4210
4343
  groupGrowable[gi] = isGrowableVW;
4211
4344
  groupGlowing[gi] = isGlowingVW;
4212
4345
  groupCharRise[gi] = isCharRiseVW;
4346
+ groupCharDrag[gi] = isCharDragVW;
4213
4347
  vwFullText[gi] = combinedText;
4214
4348
  vwFullDuration[gi] = combinedDuration;
4215
4349
  vwCharOffset[gi] = charOff;
@@ -4227,6 +4361,7 @@ export class AmLyrics extends LitElement {
4227
4361
  groupGrowable,
4228
4362
  groupGlowing,
4229
4363
  groupCharRise,
4364
+ groupCharDrag,
4230
4365
  vwFullText,
4231
4366
  vwFullDuration,
4232
4367
  vwCharOffset,
@@ -4248,62 +4383,135 @@ export class AmLyrics extends LitElement {
4248
4383
 
4249
4384
  const computedStyle = getComputedStyle(referenceSyllable);
4250
4385
  const { font } = computedStyle; // Full font string
4251
- const fontSize = parseFloat(computedStyle.fontSize);
4252
4386
 
4253
- const charTimedWords = this.shadowRoot.querySelectorAll(
4254
- '.lyrics-word.growable, .lyrics-word.char-rise',
4255
- );
4256
- if (!charTimedWords) return;
4257
-
4258
- charTimedWords.forEach((wordSpan: any) => {
4259
- 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
+ });
4260
4404
 
4261
- // Flatten syllables
4405
+ wordsByVirtualId.forEach(wordSpans => {
4262
4406
  const syllables: HTMLElement[] = [];
4263
- syllableWraps.forEach((wrap: HTMLElement) => {
4264
- const syl = wrap.querySelector('.lyrics-syllable');
4265
- 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
+ });
4266
4415
  });
4267
4416
 
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;
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;
4293
4426
 
4294
- if (totalSyllableWidth > 0) {
4295
- const startPercent = cumulativeCharWidth / totalSyllableWidth;
4296
- const durationPercent = charWidth / totalSyllableWidth;
4427
+ wordSpans.forEach(wordSpan => {
4428
+ const target = wordSpan as any;
4429
+ target._cachedVirtualWordElements = wordSpans;
4430
+ target._cachedVirtualWordCharSpans = charSpans;
4431
+ });
4297
4432
 
4298
- span.dataset.wipeStart = startPercent.toFixed(4);
4299
- 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;
4300
4453
 
4301
- // The critical missing piece:
4302
- span.dataset.preWipeArrival = (duration * startPercent).toFixed(2);
4303
- 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
+ );
4304
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
+ );
4305
4512
 
4306
4513
  cumulativeCharWidth += charWidth;
4514
+ cumulativeSyllableWidth += charWidth;
4307
4515
  });
4308
4516
  });
4309
4517
  });
@@ -4313,6 +4521,37 @@ export class AmLyrics extends LitElement {
4313
4521
  return a.length === b.length && a.every((val, i) => val === b[i]);
4314
4522
  }
4315
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
+
4316
4555
  private static getLineIndexFromElement(
4317
4556
  lineElement: HTMLElement | null,
4318
4557
  ): number | null {
@@ -4350,6 +4589,31 @@ export class AmLyrics extends LitElement {
4350
4589
  this.preActiveLineElements = keptLines;
4351
4590
  }
4352
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
+
4353
4617
  private getPrimaryActiveLineIndex(activeIndices: number[]): number | null {
4354
4618
  if (activeIndices.length === 0) return null;
4355
4619
 
@@ -4593,9 +4857,10 @@ export class AmLyrics extends LitElement {
4593
4857
 
4594
4858
  for (let i = 0; i < this.lyrics.length; i += 1) {
4595
4859
  const line = this.lyrics[i];
4860
+ const highlightEndTime = this.getLineHighlightEndTime(i);
4596
4861
 
4597
4862
  if (line.timestamp > time) break;
4598
- if (time >= line.timestamp && time < line.endtime) {
4863
+ if (time >= line.timestamp && time < highlightEndTime) {
4599
4864
  activeLines.push(i);
4600
4865
  }
4601
4866
  }
@@ -4862,6 +5127,7 @@ export class AmLyrics extends LitElement {
4862
5127
  this.lastPrimaryActiveLine = null;
4863
5128
  this.activeLineIds.clear();
4864
5129
  this.animatingLines = [];
5130
+ this.setBackgroundExpandedLine(null);
4865
5131
 
4866
5132
  // Find the clicked line element and scroll to it with forceScroll (like YouLyPlus)
4867
5133
  // Timestamps are already in milliseconds — match the data-start-time attribute directly
@@ -4884,6 +5150,7 @@ export class AmLyrics extends LitElement {
4884
5150
  }, 800);
4885
5151
 
4886
5152
  this.scrollToActiveLineYouLy(clickedLineElement, true);
5153
+ this.setBackgroundExpandedLine(clickedLineElement);
4887
5154
  }
4888
5155
 
4889
5156
  const event = new CustomEvent('line-click', {
@@ -5322,44 +5589,417 @@ export class AmLyrics extends LitElement {
5322
5589
 
5323
5590
  /**
5324
5591
  * Update syllable highlight animation - apply CSS wipe animation
5325
- * (Exact copy from YouLyPlus _updateSyllableAnimation)
5326
5592
  */
5327
- private static updateSyllableAnimation(
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(
5328
5733
  syllable: HTMLElement,
5329
- elapsedTimeMs = 0,
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,
5330
5779
  ): void {
5331
- if (syllable.classList.contains('highlight')) return;
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;
5332
5801
 
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) {
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) {
5343
5935
  wordElements = Array.from(
5344
5936
  wordElement.parentElement.querySelectorAll('.lyrics-word'),
5345
5937
  ).filter(
5346
5938
  el => (el as HTMLElement).dataset.virtualWordId === virtualWordId,
5347
5939
  ) as HTMLElement[];
5348
- } else if (wordElement) {
5349
- wordElements = [wordElement as HTMLElement];
5350
5940
  }
5351
- const allWordCharSpans = wordElements.flatMap(
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(
5352
5963
  word => Array.from(word.querySelectorAll('span.char')) as HTMLElement[],
5353
5964
  );
5354
- const isGrowable = wordElement?.classList.contains('growable');
5355
- const isCharRise = wordElement?.classList.contains('char-rise');
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
+
5975
+ private static updateSyllableAnimation(
5976
+ syllable: HTMLElement,
5977
+ elapsedTimeMs = 0,
5978
+ ): void {
5979
+ if (syllable.classList.contains('highlight')) return;
5980
+
5981
+ const { classList } = syllable;
5982
+ const hadPreHighlight = classList.contains('pre-highlight');
5983
+ const isRTL = classList.contains('rtl-text');
5984
+ const charSpans = AmLyrics.getCachedCharSpans(syllable);
5985
+ const wordElement = syllable.parentElement?.parentElement; // syllable-wrap -> word
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');
5356
5996
  const isFirstSyllable =
5357
5997
  syllable.getAttribute('data-syllable-index') === '0';
5358
5998
  const syllableStartMs = parseFloat(
5359
5999
  syllable.getAttribute('data-start-time') || '0',
5360
6000
  );
5361
6001
  const virtualWordStartMs = parseFloat(
5362
- (wordElement as HTMLElement | undefined)?.dataset.virtualWordStart || '',
6002
+ typedWordElement?.dataset.virtualWordStart || '',
5363
6003
  );
5364
6004
  const isFirstInVirtualWord =
5365
6005
  isFirstSyllable &&
@@ -5377,8 +6017,11 @@ export class AmLyrics extends LitElement {
5377
6017
  syllable.getAttribute('data-duration') ||
5378
6018
  '0',
5379
6019
  ) || syllableDurationMs;
6020
+ const wordElapsedTimeMs = Number.isFinite(virtualWordStartMs)
6021
+ ? elapsedTimeMs + (syllableStartMs - virtualWordStartMs)
6022
+ : elapsedTimeMs;
6023
+ const charWipeDurationMs = Math.max(wordDurationMs, syllableDurationMs);
5380
6024
 
5381
- // Use a Map to collect animations like YouLyPlus
5382
6025
  const charAnimationsMap = new Map<HTMLElement, string>();
5383
6026
  const styleUpdates: Array<{
5384
6027
  element: HTMLElement;
@@ -5446,18 +6089,82 @@ export class AmLyrics extends LitElement {
5446
6089
  });
5447
6090
  }
5448
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
+
5449
6108
  // Step 2: Wipe Pass
5450
6109
  if (charSpans.length > 0) {
5451
- 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) => {
5452
6141
  const startPct = parseFloat(span.dataset.wipeStart || '0');
5453
6142
  const durationPct = parseFloat(span.dataset.wipeDuration || '0');
6143
+ const globalCharIndex = parseFloat(
6144
+ span.dataset.syllableCharIndex || `${charIndex}`,
6145
+ );
5454
6146
 
5455
- const wipeDelay = syllableDurationMs * startPct - elapsedTimeMs;
5456
- const wipeDuration = syllableDurationMs * durationPct;
5457
-
5458
- 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;
5459
6162
  let charWipeAnimation = 'wipe';
5460
- if (useStartAnimation) {
6163
+ if (hadCharPreWipe) {
6164
+ charWipeAnimation = 'wipe-word-from-pre';
6165
+ wipeDelay = -wordElapsedTimeMs;
6166
+ wipeDuration = charWipeDurationMs;
6167
+ } else if (useStartAnimation) {
5461
6168
  charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
5462
6169
  } else {
5463
6170
  charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
@@ -5471,36 +6178,15 @@ export class AmLyrics extends LitElement {
5471
6178
  if (
5472
6179
  existingAnimation &&
5473
6180
  (existingAnimation.includes('grow-dynamic') ||
5474
- existingAnimation.includes('rise-char'))
6181
+ existingAnimation.includes('rise-char') ||
6182
+ existingAnimation.includes('drag-char'))
5475
6183
  ) {
5476
6184
  animationParts.push(existingAnimation.split(',')[0].trim());
5477
6185
  }
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(
5484
- span.dataset.preWipeDuration || '100',
5485
- );
5486
- const preWipeDuration = Math.min(
5487
- measuredPreWipeDuration,
5488
- wipeDuration * 0.9,
5489
- syllableDurationMs * 0.08,
5490
- arrivalTime,
5491
- );
5492
- const animDelay = arrivalTime - preWipeDuration;
5493
-
5494
- if (preWipeDuration >= 16) {
5495
- animationParts.push(
5496
- `pre-wipe-char ${preWipeDuration}ms linear ${animDelay}ms none`,
5497
- );
5498
- }
5499
- }
5500
-
5501
6186
  if (wipeDuration > 0) {
6187
+ const wipeFillMode = hadCharPreWipe ? 'both' : 'forwards';
5502
6188
  animationParts.push(
5503
- `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`,
6189
+ `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms ${wipeFillMode}`,
5504
6190
  );
5505
6191
  }
5506
6192
 
@@ -5513,10 +6199,15 @@ export class AmLyrics extends LitElement {
5513
6199
  const wipeRatio = parseFloat(
5514
6200
  syllable.getAttribute('data-wipe-ratio') || '1',
5515
6201
  );
5516
- 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);
5517
6206
 
5518
6207
  let wipeAnimation = 'wipe';
5519
- if (isFirstInContainer) {
6208
+ if (hadPreHighlight) {
6209
+ wipeAnimation = isRTL ? 'wipe-from-pre-rtl' : 'wipe-from-pre';
6210
+ } else if (isFirstInContainer) {
5520
6211
  wipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
5521
6212
  } else {
5522
6213
  wipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
@@ -5530,18 +6221,28 @@ export class AmLyrics extends LitElement {
5530
6221
  }
5531
6222
 
5532
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
+
5533
6231
  classList.remove('pre-highlight');
5534
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
+ }
5535
6240
 
5536
6241
  for (const [span, animationString] of charAnimationsMap.entries()) {
5537
6242
  span.style.willChange = 'transform';
6243
+ span.style.removeProperty('background-color');
5538
6244
  span.style.animation = animationString;
5539
6245
  }
5540
-
5541
- // Apply style updates
5542
- for (const update of styleUpdates) {
5543
- update.element.style.setProperty(update.property, update.value);
5544
- }
5545
6246
  }
5546
6247
 
5547
6248
  /**
@@ -5566,6 +6267,7 @@ export class AmLyrics extends LitElement {
5566
6267
  el.style.animation = '';
5567
6268
  el.style.transition = 'none';
5568
6269
  el.style.backgroundColor = 'var(--lyplus-text-secondary)';
6270
+ AmLyrics.clearPreWipeLead(el);
5569
6271
  }
5570
6272
 
5571
6273
  // Immediately remove all state classes
@@ -5577,12 +6279,22 @@ export class AmLyrics extends LitElement {
5577
6279
  );
5578
6280
  }
5579
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
+
5580
6291
  /**
5581
6292
  * Reset all syllables in a line — batches deferred cleanup into a single rAF
5582
6293
  */
5583
6294
  private static resetSyllables(line: HTMLElement): void {
5584
6295
  if (!line) return;
5585
6296
  line.classList.remove('persist-highlight');
6297
+ AmLyrics.resetWordAnimationState(line);
5586
6298
  // eslint-disable-next-line no-param-reassign
5587
6299
  (line as any)._cachedSyllableElements = null;
5588
6300
  const syllables = line.getElementsByClassName('lyrics-syllable');
@@ -5614,6 +6326,7 @@ export class AmLyrics extends LitElement {
5614
6326
  private static unfinishSyllables(line: HTMLElement): void {
5615
6327
  if (!line) return;
5616
6328
  line.classList.remove('persist-highlight');
6329
+ AmLyrics.resetWordAnimationState(line);
5617
6330
  const syllables = line.getElementsByClassName('lyrics-syllable');
5618
6331
  for (let i = 0; i < syllables.length; i += 1) {
5619
6332
  const s = syllables[i] as HTMLElement;
@@ -5631,6 +6344,7 @@ export class AmLyrics extends LitElement {
5631
6344
  el.style.removeProperty('background-color');
5632
6345
  el.style.removeProperty('transition');
5633
6346
  el.style.removeProperty('filter');
6347
+ AmLyrics.clearPreWipeLead(el);
5634
6348
  }
5635
6349
  }
5636
6350
  }
@@ -5681,22 +6395,33 @@ export class AmLyrics extends LitElement {
5681
6395
  syllable.style.animation = '';
5682
6396
  syllable.style.removeProperty('--pre-wipe-duration');
5683
6397
  syllable.style.removeProperty('--pre-wipe-delay');
6398
+ syllable.style.removeProperty('background-color');
6399
+ AmLyrics.applyWipeShape(
6400
+ syllable,
6401
+ AmLyrics.getVisibleCharacterCount(syllable),
6402
+ );
5684
6403
  const chars = syllable.querySelectorAll('span.char');
5685
6404
  for (let ci = 0; ci < chars.length; ci += 1) {
5686
6405
  const charEl = chars[ci] as HTMLElement;
5687
6406
  const currentAnim = charEl.style.animation || '';
5688
6407
  if (
5689
6408
  currentAnim.includes('grow-dynamic') ||
5690
- currentAnim.includes('rise-char')
6409
+ currentAnim.includes('rise-char') ||
6410
+ currentAnim.includes('drag-char')
5691
6411
  ) {
5692
6412
  const parts = currentAnim.split(',').map(p => p.trim());
5693
6413
  const transformAnim = parts.find(
5694
- 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'),
5695
6418
  );
5696
6419
  charEl.style.animation = transformAnim || '';
5697
6420
  } else {
5698
6421
  charEl.style.animation = '';
5699
6422
  }
6423
+ charEl.style.backgroundColor = 'var(--lyplus-text-primary)';
6424
+ AmLyrics.clearPreWipeLead(charEl);
5700
6425
  }
5701
6426
  }
5702
6427
  }
@@ -5751,14 +6476,20 @@ export class AmLyrics extends LitElement {
5751
6476
  if (!(currentTimeMs < startTime - 1000 && !hasActiveState)) {
5752
6477
  let preHighlightReset = false;
5753
6478
 
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 = '';
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);
5762
6493
  preHighlightReset = true;
5763
6494
  }
5764
6495
  }
@@ -5791,6 +6522,8 @@ export class AmLyrics extends LitElement {
5791
6522
  // Not yet started
5792
6523
  AmLyrics.resetSyllable(syllable);
5793
6524
  }
6525
+
6526
+ AmLyrics.maybePreWipeNextWord(syllables, i, currentTimeMs, endTime);
5794
6527
  }
5795
6528
  }
5796
6529
  }
@@ -6151,6 +6884,9 @@ export class AmLyrics extends LitElement {
6151
6884
  data-end-time="${endTimeMs}"
6152
6885
  data-duration="${durationMs}"
6153
6886
  data-syllable-index="${syllableIndex}"
6887
+ data-word-index="${syllableIndex}"
6888
+ data-word-length="${syllable.text.replace(/\s/g, '')
6889
+ .length}"
6154
6890
  data-wipe-ratio="1"
6155
6891
  >${syllable.text}</span
6156
6892
  >${bgRomanizedText}</span
@@ -6175,6 +6911,7 @@ export class AmLyrics extends LitElement {
6175
6911
  const groupGrowable = lineData?.groupGrowable ?? [];
6176
6912
  const groupGlowing = lineData?.groupGlowing ?? [];
6177
6913
  const groupCharRise = lineData?.groupCharRise ?? [];
6914
+ const groupCharDrag = lineData?.groupCharDrag ?? [];
6178
6915
  const vwFullText = lineData?.vwFullText ?? [];
6179
6916
  const vwFullDuration = lineData?.vwFullDuration ?? [];
6180
6917
  const vwCharOffset = lineData?.vwCharOffset ?? [];
@@ -6182,7 +6919,6 @@ export class AmLyrics extends LitElement {
6182
6919
  const vwEndMs = lineData?.vwEndMs ?? [];
6183
6920
  const lineIsRTL = lineData?.lineIsRTL ?? false;
6184
6921
 
6185
- // Create main vocals using YouLyPlus syllable structure
6186
6922
  const mainVocalElement = html`<p
6187
6923
  class="main-vocal-container ${lineIsRTL ? 'rtl-text' : ''}"
6188
6924
  >
@@ -6190,28 +6926,26 @@ export class AmLyrics extends LitElement {
6190
6926
  const isGrowable = groupGrowable[groupIdx];
6191
6927
  const isGlowing = groupGlowing[groupIdx];
6192
6928
  const isCharRise = groupCharRise[groupIdx];
6193
- const isAnimatedByChar = isGrowable || isCharRise;
6929
+ const isCharDrag = groupCharDrag[groupIdx];
6930
+ const isAnimatedByChar = isGrowable || isCharRise || isCharDrag;
6194
6931
  const groupLineSynced = group.some(s => s.lineSynced);
6195
6932
 
6196
6933
  const wordText = isAnimatedByChar ? vwFullText[groupIdx] : '';
6197
6934
  const wordDuration = isAnimatedByChar
6198
6935
  ? vwFullDuration[groupIdx]
6199
6936
  : 0;
6200
- const wordNumChars = wordText.length;
6937
+ const wordNumChars = wordText.replace(/\s/g, '').length;
6201
6938
  const groupCharOffset = isAnimatedByChar
6202
6939
  ? vwCharOffset[groupIdx]
6203
6940
  : 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] : '';
6941
+ const virtualWordId = `${lineIndex}:${vwStartMs[groupIdx]}:${vwEndMs[groupIdx]}`;
6942
+ const virtualWordStart = vwStartMs[groupIdx];
6943
+ const virtualWordEnd = vwEndMs[groupIdx];
6211
6944
 
6212
6945
  let sylCharAccumulator = 0;
6213
6946
 
6214
6947
  const groupText = group.map(s => s.text).join('');
6948
+ const visibleWordLength = groupText.replace(/\s/g, '').length;
6215
6949
  const shouldAllowBreak =
6216
6950
  groupText.trim().length >= 16 ||
6217
6951
  /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(
@@ -6231,9 +6965,9 @@ export class AmLyrics extends LitElement {
6231
6965
  return html`<span
6232
6966
  class="lyrics-word${isGrowable ? ' growable' : ''}${isCharRise
6233
6967
  ? ' char-rise'
6234
- : ''}${isGlowing ? ' glowing' : ''}${shouldAllowBreak
6235
- ? ' allow-break'
6236
- : ''}"
6968
+ : ''}${isCharDrag ? ' char-drag' : ''}${isGlowing
6969
+ ? ' glowing'
6970
+ : ''}${shouldAllowBreak ? ' allow-break' : ''}"
6237
6971
  data-virtual-word-id="${virtualWordId}"
6238
6972
  data-virtual-word-start="${virtualWordStart}"
6239
6973
  data-virtual-word-end="${virtualWordEnd}"
@@ -6264,17 +6998,39 @@ export class AmLyrics extends LitElement {
6264
6998
  let syllableContent: any = text;
6265
6999
 
6266
7000
  if (isAnimatedByChar) {
6267
- let charIndexInsideSyllable = 0;
6268
7001
  const numCharsInSyllable =
6269
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;
6270
7016
 
6271
7017
  syllableContent = html`${text.split('').map(char => {
6272
7018
  if (char === ' ') return ' ';
6273
7019
 
6274
7020
  const charIndexInsideWord =
6275
7021
  groupCharOffset + sylCharAccumulator;
6276
- const charStartPercentVal =
6277
- 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;
6278
7034
 
6279
7035
  sylCharAccumulator += 1;
6280
7036
  charIndexInsideSyllable += 1;
@@ -6336,29 +7092,41 @@ export class AmLyrics extends LitElement {
6336
7092
  1,
6337
7093
  Math.max(0.3, effectiveDuration / 2000),
6338
7094
  );
6339
- const charTranslateYPeak =
7095
+ const baseTranslateYPeak =
6340
7096
  -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
6341
7097
 
6342
7098
  const position = (charIndexInsideWord + 0.5) / wordNumChars;
6343
7099
  const horizontalOffset =
6344
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
+ }
6345
7108
 
7109
+ let motionHorizontalOffset = horizontalOffset;
7110
+ if (isCharRise) {
7111
+ motionHorizontalOffset = 0;
7112
+ } else if (isDragMotion) {
7113
+ motionHorizontalOffset = 0;
7114
+ }
6346
7115
  return html`<span
6347
7116
  class="char"
6348
7117
  data-char-index="${charIndexInsideWord}"
6349
7118
  data-syllable-char-index="${charIndexInsideWord}"
6350
7119
  data-wipe-start="${charStartPercentVal.toFixed(4)}"
6351
- data-wipe-duration="${(1 / numCharsInSyllable).toFixed(
6352
- 4,
6353
- )}"
7120
+ data-wipe-duration="${charDurationPercentVal.toFixed(4)}"
6354
7121
  data-horizontal-offset="${horizontalOffset.toFixed(2)}"
6355
7122
  data-max-scale="${charMaxScale.toFixed(3)}"
6356
7123
  data-matrix-scale="${(charMaxScale * 0.98).toFixed(3)}"
6357
- data-char-offset-x="${(horizontalOffset * 0.98).toFixed(
6358
- 2,
6359
- )}"
7124
+ data-char-offset-x="${(
7125
+ motionHorizontalOffset * 0.98
7126
+ ).toFixed(2)}"
6360
7127
  data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
6361
7128
  data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
7129
+ style="--word-wipe-width: ${visibleWordChars}ch; --char-wipe-position: -${charIndexInsideWord}ch"
6362
7130
  >${char}</span
6363
7131
  >`;
6364
7132
  })}`;
@@ -6377,6 +7145,8 @@ export class AmLyrics extends LitElement {
6377
7145
  data-duration="${durationMs}"
6378
7146
  data-word-duration="${wordDuration}"
6379
7147
  data-syllable-index="${sylIdx}"
7148
+ data-word-index="${groupIdx}"
7149
+ data-word-length="${visibleWordLength}"
6380
7150
  data-wipe-ratio="1"
6381
7151
  >${syllableContent}</span
6382
7152
  >${romanizedText}</span
@@ -6617,13 +7387,14 @@ export class AmLyrics extends LitElement {
6617
7387
  <button
6618
7388
  class="download-button source-switch-btn"
6619
7389
  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
7390
  @click=${this.switchSource}
6622
7391
  ?disabled=${this.isFetchingAlternatives}
6623
7392
  >
6624
7393
  <svg
6625
- class="source-switch-svg lucide lucide-arrow-down-up-icon lucide-arrow-down-up"
6626
- 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
+ : ''}"
6627
7398
  xmlns="http://www.w3.org/2000/svg"
6628
7399
  width="12"
6629
7400
  height="12"