@uimaxbai/am-lyrics 1.1.7 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/react.js CHANGED
@@ -88,6 +88,7 @@ const CONFIG = {
88
88
  GOOGLE: {
89
89
  MAX_RETRIES: 3,
90
90
  RETRY_DELAY_MS: 1000,
91
+ FETCH_TIMEOUT_MS: 6000,
91
92
  },
92
93
  };
93
94
  /**
@@ -99,6 +100,11 @@ class GoogleService {
99
100
  setTimeout(resolve, ms);
100
101
  });
101
102
  }
103
+ static fetchWithTimeout(url, timeoutMs = CONFIG.GOOGLE.FETCH_TIMEOUT_MS) {
104
+ const controller = new AbortController();
105
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
106
+ return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeoutId));
107
+ }
102
108
  static isPurelyLatinScript(text) {
103
109
  // Basic check for Latin script characters plus common punctuation and numbers
104
110
  // eslint-disable-next-line no-control-regex
@@ -140,7 +146,7 @@ class GoogleService {
140
146
  try {
141
147
  const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${targetLang}&dt=t&q=${encodeURIComponent(joinedText)}`;
142
148
  // eslint-disable-next-line no-await-in-loop
143
- const response = await fetch(url);
149
+ const response = await GoogleService.fetchWithTimeout(url);
144
150
  if (!response.ok)
145
151
  throw new Error(`Status ${response.status}`);
146
152
  // eslint-disable-next-line no-await-in-loop
@@ -213,9 +219,11 @@ class GoogleService {
213
219
  // Determine if we should treat as word-synced (has syllabus) or line-synced
214
220
  const lines = Array.isArray(originalLyrics)
215
221
  ? originalLyrics
216
- : originalLyrics.data || originalLyrics.content;
217
- if (!lines)
218
- return originalLyrics;
222
+ : originalLyrics.data ||
223
+ originalLyrics.content ||
224
+ [];
225
+ if (!lines || lines.length === 0)
226
+ return Array.isArray(originalLyrics) ? originalLyrics : [];
219
227
  // Check if word synced
220
228
  const isWordSynced = lines.some((l) => l.isWordSynced !== false && Array.isArray(l.text) && l.text.length > 1);
221
229
  if (isWordSynced) {
@@ -232,7 +240,9 @@ class GoogleService {
232
240
  )
233
241
  return line;
234
242
  // Get the entire line text to romanize together for context-aware pronunciation
235
- const fullText = line.text.map((s) => s.text).join('');
243
+ const fullText = line.text
244
+ .map((s) => s.text)
245
+ .join('');
236
246
  // romanizeTexts expects an array of strings, so we pass an array of one
237
247
  const [romanizedFullLine] = await this.romanizeTexts([fullText]);
238
248
  const newSyllabus = line.text.map((s) => ({
@@ -282,7 +292,7 @@ class GoogleService {
282
292
  while (attempt < CONFIG.GOOGLE.MAX_RETRIES && !success) {
283
293
  try {
284
294
  const romanizeUrl = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=en&dt=rm&q=${encodeURIComponent(text)}`;
285
- const response = await fetch(romanizeUrl);
295
+ const response = await GoogleService.fetchWithTimeout(romanizeUrl);
286
296
  const data = await response.json();
287
297
  // Response format is [[["...","...","...","romanization"]],...]
288
298
  // or [null, ...] for English/Latin input where no romanization is needed
@@ -312,8 +322,26 @@ class GoogleService {
312
322
  }
313
323
  }
314
324
 
315
- const VERSION = '1.1.7';
325
+ const VERSION = '1.2.0';
316
326
  const INSTRUMENTAL_THRESHOLD_MS = 7000; // Show dots for gaps >= 7s
327
+ const FETCH_TIMEOUT_MS = 8000; // Timeout for all lyrics fetch requests
328
+ const SEEK_THRESHOLD_MS = 500;
329
+ const PRE_SCROLL_LEAD_MS = 500;
330
+ const SCROLL_ANIMATION_DURATION_MS = 280;
331
+ const SCROLL_DELAY_INCREMENT_MS = 24;
332
+ const GAP_PULSE_DURATION_MS = 4000;
333
+ const GAP_PULSE_CYCLE_MS = GAP_PULSE_DURATION_MS * 2;
334
+ const GAP_EXIT_LEAD_MS = 360;
335
+ const GAP_MIN_SCALE = 0.85;
336
+ /**
337
+ * Fetch with an automatic timeout via AbortSignal.
338
+ * Rejects if the request takes longer than `timeoutMs`.
339
+ */
340
+ function fetchWithTimeout(url, options = {}, timeoutMs = FETCH_TIMEOUT_MS) {
341
+ const controller = new AbortController();
342
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
343
+ return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timeoutId));
344
+ }
317
345
  const KPOE_SERVERS = [
318
346
  'https://lyricsplus.binimum.org',
319
347
  'https://lyricsplus.atomix.one',
@@ -377,6 +405,9 @@ let AmLyrics$1 = class AmLyrics extends i {
377
405
  // Syllable animation tracking
378
406
  this.lastActiveIndex = 0;
379
407
  this.visibleLineIds = new Set();
408
+ // Bound handler references for proper event listener removal
409
+ this._boundHandleUserScroll = this.handleUserScroll.bind(this);
410
+ this._boundAnimateProgress = this.animateProgress.bind(this);
380
411
  }
381
412
  async toggleRomanization() {
382
413
  this.showRomanization = !this.showRomanization;
@@ -462,12 +493,38 @@ let AmLyrics$1 = class AmLyrics extends i {
462
493
  super.disconnectedCallback();
463
494
  if (this.animationFrameId) {
464
495
  cancelAnimationFrame(this.animationFrameId);
496
+ this.animationFrameId = undefined;
465
497
  }
466
498
  if (this.userScrollTimeoutId) {
467
499
  clearTimeout(this.userScrollTimeoutId);
500
+ this.userScrollTimeoutId = undefined;
501
+ }
502
+ if (this.clickSeekTimeout) {
503
+ clearTimeout(this.clickSeekTimeout);
504
+ this.clickSeekTimeout = undefined;
505
+ }
506
+ if (this.scrollUnlockTimeout) {
507
+ clearTimeout(this.scrollUnlockTimeout);
508
+ this.scrollUnlockTimeout = undefined;
509
+ }
510
+ if (this.scrollAnimationTimeout) {
511
+ clearTimeout(this.scrollAnimationTimeout);
512
+ this.scrollAnimationTimeout = undefined;
513
+ }
514
+ // Cancel any in-flight fetch requests
515
+ this.fetchAbortController?.abort();
516
+ this.fetchAbortController = undefined;
517
+ // Remove scroll event listeners
518
+ if (this.lyricsContainer) {
519
+ this.lyricsContainer.removeEventListener('wheel', this._boundHandleUserScroll);
520
+ this.lyricsContainer.removeEventListener('touchmove', this._boundHandleUserScroll);
468
521
  }
469
522
  }
470
523
  async fetchLyrics() {
524
+ // Cancel any in-flight fetch to prevent stale results from racing
525
+ this.fetchAbortController?.abort();
526
+ const controller = new AbortController();
527
+ this.fetchAbortController = controller;
471
528
  this.isLoading = true;
472
529
  this.lyrics = undefined;
473
530
  this.lyricsSource = null;
@@ -477,6 +534,9 @@ let AmLyrics$1 = class AmLyrics extends i {
477
534
  this.hasFetchedAllProviders = false;
478
535
  try {
479
536
  const resolvedMetadata = await this.resolveSongMetadata();
537
+ // If a newer fetch was triggered while we awaited, bail out
538
+ if (controller.signal.aborted)
539
+ return;
480
540
  const isMusicIdOnlyRequest = Boolean(this.musicId) &&
481
541
  !this.songTitle &&
482
542
  !this.songArtist &&
@@ -535,7 +595,10 @@ let AmLyrics$1 = class AmLyrics extends i {
535
595
  this.lyricsSource = null;
536
596
  }
537
597
  finally {
538
- this.isLoading = false;
598
+ // Only update loading state if this fetch wasn't superseded
599
+ if (!controller.signal.aborted) {
600
+ this.isLoading = false;
601
+ }
539
602
  }
540
603
  }
541
604
  async onLyricsLoaded() {
@@ -796,7 +859,7 @@ let AmLyrics$1 = class AmLyrics extends i {
796
859
  const url = `${normalizedBase}/v1/songlist/search?q=${encodeURIComponent(trimmedQuery)}`;
797
860
  try {
798
861
  // eslint-disable-next-line no-await-in-loop
799
- const response = await fetch(url);
862
+ const response = await fetchWithTimeout(url);
800
863
  if (response.ok) {
801
864
  // eslint-disable-next-line no-await-in-loop
802
865
  const payload = await response.json();
@@ -830,7 +893,9 @@ let AmLyrics$1 = class AmLyrics extends i {
830
893
  if (metadata.durationMs && metadata.durationMs > 0) {
831
894
  params.append('duration', Math.round(metadata.durationMs / 1000).toString());
832
895
  }
833
- params.append('source', DEFAULT_KPOE_SOURCE_ORDER);
896
+ if (!DEFAULT_KPOE_SOURCE_ORDER.includes('apple')) {
897
+ params.append('source', DEFAULT_KPOE_SOURCE_ORDER);
898
+ }
834
899
  const getRank = (sourceLabel, parsedLines) => {
835
900
  const lower = sourceLabel.toLowerCase();
836
901
  const hasWordSync = parsedLines.some((line) => line.text && Array.isArray(line.text) && line.text.length > 1);
@@ -875,13 +940,13 @@ let AmLyrics$1 = class AmLyrics extends i {
875
940
  cacheParams.append('duration', Math.round(metadata.durationMs / 1000).toString());
876
941
  }
877
942
  const cacheUrl = `https://lyrics-api.binimum.org/?${cacheParams.toString()}`;
878
- const cacheRes = await fetch(cacheUrl);
943
+ const cacheRes = await fetchWithTimeout(cacheUrl);
879
944
  if (cacheRes.ok) {
880
945
  const cacheData = await cacheRes.json();
881
946
  if (cacheData.results && cacheData.results.length > 0) {
882
947
  const result = cacheData.results[0];
883
948
  if (result.timing_type === 'word' && result.lyricsUrl) {
884
- const ttmlRes = await fetch(result.lyricsUrl);
949
+ const ttmlRes = await fetchWithTimeout(result.lyricsUrl);
885
950
  if (ttmlRes.ok) {
886
951
  const ttmlText = await ttmlRes.text();
887
952
  const lines = AmLyrics.parseTTML(ttmlText);
@@ -896,7 +961,7 @@ let AmLyrics$1 = class AmLyrics extends i {
896
961
  const fallbackParams = new URLSearchParams(params);
897
962
  const fallbackUrl = `https://lyricsplus.binimum.org/v2/lyrics/get?${fallbackParams.toString()}`;
898
963
  try {
899
- const fallbackRes = await fetch(fallbackUrl);
964
+ const fallbackRes = await fetchWithTimeout(fallbackUrl);
900
965
  if (fallbackRes.ok) {
901
966
  const payload = await fallbackRes.json();
902
967
  const lines = AmLyrics.convertKPoeLyrics(payload);
@@ -917,7 +982,7 @@ let AmLyrics$1 = class AmLyrics extends i {
917
982
  }
918
983
  // If fallback fails or has no word sync, fall back to bini lyrics
919
984
  if (result.lyricsUrl) {
920
- const ttmlRes = await fetch(result.lyricsUrl);
985
+ const ttmlRes = await fetchWithTimeout(result.lyricsUrl);
921
986
  if (ttmlRes.ok) {
922
987
  const ttmlText = await ttmlRes.text();
923
988
  const lines = AmLyrics.parseTTML(ttmlText);
@@ -939,23 +1004,23 @@ let AmLyrics$1 = class AmLyrics extends i {
939
1004
  console.error('Cache API failed', e);
940
1005
  }
941
1006
  // Shuffle servers so we pick a random one first, with all others as fallback
942
- // Limit to 2 servers to prevent unnecessary API spam when Apple lyrics are missing
1007
+ // Try up to 3 servers to improve reliability when some have CORS or connectivity issues
943
1008
  const shuffledServers = [...KPOE_SERVERS]
944
1009
  .sort(() => Math.random() - 0.5)
945
- .slice(0, 2);
1010
+ .slice(0, 3);
946
1011
  for (const base of shuffledServers) {
947
1012
  const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
948
1013
  const url = `${normalizedBase}/v2/lyrics/get?${params.toString()}`;
949
1014
  let payload = null;
950
1015
  try {
951
1016
  // eslint-disable-next-line no-await-in-loop
952
- const response = await fetch(url);
1017
+ const response = await fetchWithTimeout(url);
953
1018
  if (response.ok) {
954
1019
  // eslint-disable-next-line no-await-in-loop
955
1020
  payload = await response.json();
956
1021
  }
957
1022
  }
958
- catch (error) {
1023
+ catch {
959
1024
  payload = null;
960
1025
  }
961
1026
  if (payload) {
@@ -981,7 +1046,7 @@ let AmLyrics$1 = class AmLyrics extends i {
981
1046
  try {
982
1047
  const fallbackParams = new URLSearchParams(params);
983
1048
  const url = `https://lyricsplus.binimum.org/v2/lyrics/get?${fallbackParams.toString()}`;
984
- const response = await fetch(url);
1049
+ const response = await fetchWithTimeout(url);
985
1050
  if (response.ok) {
986
1051
  const payload = await response.json();
987
1052
  if (payload) {
@@ -1017,7 +1082,6 @@ let AmLyrics$1 = class AmLyrics extends i {
1017
1082
  if (!match) {
1018
1083
  // Skip non-timestamped lines (headers like [ti:], [ar:], etc.)
1019
1084
  // eslint-disable-next-line no-continue
1020
- // eslint-disable-next-line no-continue
1021
1085
  continue;
1022
1086
  }
1023
1087
  const minutes = parseInt(match[1], 10);
@@ -1037,7 +1101,6 @@ let AmLyrics$1 = class AmLyrics extends i {
1037
1101
  const endtime = i + 1 < parsed.length ? parsed[i + 1].timestamp : timestamp + 5000;
1038
1102
  // Skip empty lines (instrumental gaps)
1039
1103
  if (!text.trim()) {
1040
- // eslint-disable-next-line no-continue
1041
1104
  // eslint-disable-next-line no-continue
1042
1105
  continue;
1043
1106
  }
@@ -1069,9 +1132,9 @@ let AmLyrics$1 = class AmLyrics extends i {
1069
1132
  const artist = metadata.artist?.trim();
1070
1133
  if (!title || !artist)
1071
1134
  return null;
1072
- // Pick 2 random unique servers
1135
+ // Pick 3 random unique servers for better reliability
1073
1136
  const shuffled = [...TIDAL_SERVERS].sort(() => Math.random() - 0.5);
1074
- const serversToTry = shuffled.slice(0, 2);
1137
+ const serversToTry = shuffled.slice(0, 3);
1075
1138
  for (const base of serversToTry) {
1076
1139
  try {
1077
1140
  const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
@@ -1079,9 +1142,8 @@ let AmLyrics$1 = class AmLyrics extends i {
1079
1142
  const searchQuery = `${title} ${artist}`;
1080
1143
  const searchParams = new URLSearchParams({ s: searchQuery });
1081
1144
  // eslint-disable-next-line no-await-in-loop
1082
- const searchResponse = await fetch(`${normalizedBase}/search/?${searchParams.toString()}`);
1145
+ const searchResponse = await fetchWithTimeout(`${normalizedBase}/search/?${searchParams.toString()}`);
1083
1146
  if (!searchResponse.ok) {
1084
- // eslint-disable-next-line no-continue
1085
1147
  // eslint-disable-next-line no-continue
1086
1148
  continue;
1087
1149
  }
@@ -1089,7 +1151,6 @@ let AmLyrics$1 = class AmLyrics extends i {
1089
1151
  const searchData = await searchResponse.json();
1090
1152
  const items = searchData?.data?.items;
1091
1153
  if (!Array.isArray(items) || items.length === 0) {
1092
- // eslint-disable-next-line no-continue
1093
1154
  // eslint-disable-next-line no-continue
1094
1155
  continue;
1095
1156
  }
@@ -1103,15 +1164,13 @@ let AmLyrics$1 = class AmLyrics extends i {
1103
1164
  }
1104
1165
  const trackId = bestTrack?.id;
1105
1166
  if (!trackId) {
1106
- // eslint-disable-next-line no-continue
1107
1167
  // eslint-disable-next-line no-continue
1108
1168
  continue;
1109
1169
  }
1110
1170
  // Step 2: Fetch lyrics
1111
1171
  // eslint-disable-next-line no-await-in-loop
1112
- const lyricsResponse = await fetch(`${normalizedBase}/lyrics/?id=${trackId}`);
1172
+ const lyricsResponse = await fetchWithTimeout(`${normalizedBase}/lyrics/?id=${trackId}`);
1113
1173
  if (!lyricsResponse.ok) {
1114
- // eslint-disable-next-line no-continue
1115
1174
  // eslint-disable-next-line no-continue
1116
1175
  continue;
1117
1176
  }
@@ -1147,7 +1206,7 @@ let AmLyrics$1 = class AmLyrics extends i {
1147
1206
  try {
1148
1207
  const searchQuery = `${artist} ${title}`;
1149
1208
  const params = new URLSearchParams({ q: searchQuery });
1150
- const response = await fetch(`https://lrclib.net/api/search?${params.toString()}`, {
1209
+ const response = await fetchWithTimeout(`https://lrclib.net/api/search?${params.toString()}`, {
1151
1210
  headers: {
1152
1211
  'User-Agent': `apple-music-web-components/${VERSION}`,
1153
1212
  },
@@ -1205,7 +1264,7 @@ let AmLyrics$1 = class AmLyrics extends i {
1205
1264
  return null;
1206
1265
  try {
1207
1266
  const params = new URLSearchParams({ title, artist });
1208
- const response = await fetch(`${GENIUS_WORKER_URL}?${params.toString()}`);
1267
+ const response = await fetchWithTimeout(`${GENIUS_WORKER_URL}?${params.toString()}`);
1209
1268
  if (!response.ok)
1210
1269
  return null;
1211
1270
  const data = await response.json();
@@ -1236,8 +1295,7 @@ let AmLyrics$1 = class AmLyrics extends i {
1236
1295
  }
1237
1296
  }
1238
1297
  catch {
1239
- // eslint-disable-next-line no-console
1240
- console.error('No Genius lyrics found');
1298
+ // Genius fetch failed, will fall through to return null
1241
1299
  }
1242
1300
  return null;
1243
1301
  }
@@ -1329,19 +1387,6 @@ let AmLyrics$1 = class AmLyrics extends i {
1329
1387
  }
1330
1388
  }
1331
1389
  }
1332
- const transliterationNodes = doc.getElementsByTagName('transliteration');
1333
- for (let i = 0; i < transliterationNodes.length; i += 1) {
1334
- const texts = transliterationNodes[i].getElementsByTagName('text');
1335
- for (let j = 0; j < texts.length; j += 1) {
1336
- const textNode = texts[j];
1337
- const key = textNode.getAttribute('for');
1338
- if (key && textNode.textContent) {
1339
- transliterations[key] = textNode.textContent
1340
- .trim()
1341
- .replace(/\s+/g, ' ');
1342
- }
1343
- }
1344
- }
1345
1390
  const timeToMs = (timeStr) => {
1346
1391
  if (!timeStr)
1347
1392
  return 0;
@@ -1361,6 +1406,52 @@ let AmLyrics$1 = class AmLyrics extends i {
1361
1406
  }
1362
1407
  return Math.round(seconds * 1000);
1363
1408
  };
1409
+ const transliterationNodes = doc.getElementsByTagName('transliteration');
1410
+ for (let i = 0; i < transliterationNodes.length; i += 1) {
1411
+ const texts = transliterationNodes[i].getElementsByTagName('text');
1412
+ for (let j = 0; j < texts.length; j += 1) {
1413
+ const textNode = texts[j];
1414
+ const key = textNode.getAttribute('for');
1415
+ if (!key) {
1416
+ // eslint-disable-next-line no-continue
1417
+ continue;
1418
+ }
1419
+ const spans = Array.from(textNode.getElementsByTagName('span')).filter(span => span.getAttribute('begin'));
1420
+ if (spans.length > 0) {
1421
+ const syllabus = [];
1422
+ let fullText = '';
1423
+ for (let k = 0; k < spans.length; k += 1) {
1424
+ const span = spans[k];
1425
+ const begin = span.getAttribute('begin');
1426
+ const end = span.getAttribute('end');
1427
+ let spanText = span.textContent || '';
1428
+ const nextNode = span.nextSibling;
1429
+ if (nextNode &&
1430
+ nextNode.nodeType === 3 &&
1431
+ /^\s/.test(nextNode.textContent || '') &&
1432
+ !spanText.endsWith(' ')) {
1433
+ spanText += ' ';
1434
+ }
1435
+ if (spanText.trim() === '') {
1436
+ // eslint-disable-next-line no-continue
1437
+ continue;
1438
+ }
1439
+ syllabus.push({
1440
+ time: timeToMs(begin),
1441
+ duration: timeToMs(end) - timeToMs(begin),
1442
+ text: spanText,
1443
+ });
1444
+ fullText += spanText;
1445
+ }
1446
+ transliterations[key] = { text: fullText.trim(), syllabus };
1447
+ }
1448
+ else if (textNode.textContent) {
1449
+ transliterations[key] = {
1450
+ text: textNode.textContent.trim().replace(/\s+/g, ' '),
1451
+ };
1452
+ }
1453
+ }
1454
+ }
1364
1455
  const lines = [];
1365
1456
  const pNodes = doc.getElementsByTagName('p');
1366
1457
  const lineSingers = [];
@@ -1438,6 +1529,64 @@ let AmLyrics$1 = class AmLyrics extends i {
1438
1529
  });
1439
1530
  }
1440
1531
  const alignment = alignments[i];
1532
+ // Distribute line-level transliteration to individual syllables
1533
+ // so that per-syllable animated romanisation works (like KPoe lyrics)
1534
+ const lineTransliterationItem = key ? transliterations[key] : undefined;
1535
+ if (lineTransliterationItem &&
1536
+ mainSyllables.length > 1 &&
1537
+ spans.length > 0) {
1538
+ if (lineTransliterationItem.syllabus &&
1539
+ lineTransliterationItem.syllabus.length === mainSyllables.length) {
1540
+ mainSyllables.forEach((syl, mapIdx) => {
1541
+ // eslint-disable-next-line no-param-reassign
1542
+ syl.romanizedText = lineTransliterationItem.syllabus[mapIdx].text;
1543
+ });
1544
+ }
1545
+ else {
1546
+ const lineTransliteration = lineTransliterationItem.text;
1547
+ const romanWords = lineTransliteration.split(/\s+/).filter(Boolean);
1548
+ const syllableGroups = [];
1549
+ for (let si = 0; si < mainSyllables.length; si += 1) {
1550
+ if (mainSyllables[si].part && syllableGroups.length > 0) {
1551
+ syllableGroups[syllableGroups.length - 1].push(si);
1552
+ }
1553
+ else {
1554
+ syllableGroups.push([si]);
1555
+ }
1556
+ }
1557
+ const isCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(mainSyllables.map(s => s.text).join(''));
1558
+ if (romanWords.length === syllableGroups.length) {
1559
+ syllableGroups.forEach((group, gi) => {
1560
+ // eslint-disable-next-line no-param-reassign
1561
+ mainSyllables[group[0]].romanizedText = romanWords[gi];
1562
+ });
1563
+ }
1564
+ else if (romanWords.length === mainSyllables.length) {
1565
+ mainSyllables.forEach((syl, mapIdx) => {
1566
+ // eslint-disable-next-line no-param-reassign
1567
+ syl.romanizedText = romanWords[mapIdx];
1568
+ });
1569
+ }
1570
+ else if (isCJK) {
1571
+ let romanIdx = 0;
1572
+ for (const group of syllableGroups) {
1573
+ const syl = mainSyllables[group[0]];
1574
+ const sylText = group
1575
+ .map(gIndex => mainSyllables[gIndex].text)
1576
+ .join('');
1577
+ const validChars = sylText.match(/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7afA-Za-z0-9]/g) || [];
1578
+ const needed = validChars.length;
1579
+ if (needed > 0 && romanIdx < romanWords.length) {
1580
+ // eslint-disable-next-line no-param-reassign
1581
+ syl.romanizedText = romanWords
1582
+ .slice(romanIdx, romanIdx + needed)
1583
+ .join(' ');
1584
+ romanIdx += needed;
1585
+ }
1586
+ }
1587
+ }
1588
+ }
1589
+ }
1441
1590
  lines.push({
1442
1591
  text: mainSyllables,
1443
1592
  background: bgSyllables.length > 0,
@@ -1448,7 +1597,7 @@ let AmLyrics$1 = class AmLyrics extends i {
1448
1597
  alignment,
1449
1598
  songPart,
1450
1599
  translation: key ? translations[key] : undefined,
1451
- romanizedText: key ? transliterations[key] : undefined,
1600
+ romanizedText: lineTransliterationItem?.text,
1452
1601
  oppositeTurn: alignment === 'end',
1453
1602
  });
1454
1603
  }
@@ -1593,8 +1742,8 @@ let AmLyrics$1 = class AmLyrics extends i {
1593
1742
  // Use wheel/touchmove which are guaranteed to be user initiated,
1594
1743
  // unlike 'scroll' which fires for both user and programmatic/inertia
1595
1744
  if (this.lyricsContainer) {
1596
- this.lyricsContainer.addEventListener('wheel', this.handleUserScroll.bind(this), { passive: true });
1597
- this.lyricsContainer.addEventListener('touchmove', this.handleUserScroll.bind(this), { passive: true });
1745
+ this.lyricsContainer.addEventListener('wheel', this._boundHandleUserScroll, { passive: true });
1746
+ this.lyricsContainer.addEventListener('touchmove', this._boundHandleUserScroll, { passive: true });
1598
1747
  }
1599
1748
  }
1600
1749
  /**
@@ -1605,12 +1754,12 @@ let AmLyrics$1 = class AmLyrics extends i {
1605
1754
  */
1606
1755
  _onTimeChanged(oldTime, newTime) {
1607
1756
  const timeDiff = Math.abs(newTime - oldTime);
1757
+ const isSeek = timeDiff > SEEK_THRESHOLD_MS;
1608
1758
  const newActiveLines = this.findActiveLineIndices(newTime);
1609
1759
  const oldActiveLines = this.activeLineIndices;
1610
1760
  // Reset animation if active lines change or if we skip time.
1611
- // A threshold of 0.5s (500ms) is used to detect a "skip".
1612
1761
  const linesChanged = !AmLyrics.arraysEqual(newActiveLines, oldActiveLines);
1613
- if (linesChanged || timeDiff > 0.5) {
1762
+ if (linesChanged || isSeek) {
1614
1763
  // Imperatively manage 'active' class so that scroll-animate and other
1615
1764
  // imperative classes are never clobbered.
1616
1765
  if (this.lyricsContainer) {
@@ -1634,20 +1783,13 @@ let AmLyrics$1 = class AmLyrics extends i {
1634
1783
  }
1635
1784
  }
1636
1785
  }
1637
- }
1638
- this.startAnimationFromTime(newTime);
1639
- // Update position classes BEFORE scrolling so currentPrimaryActiveLine is current
1640
- if (this.lyricsContainer && this.activeLineIndices.length > 0) {
1641
- const primaryLineIndex = this.activeLineIndices[0];
1642
- const primaryLine = this.lyricsContainer.querySelector(`#lyrics-line-${primaryLineIndex}`);
1643
- if (primaryLine && primaryLine !== this.currentPrimaryActiveLine) {
1644
- this.lastPrimaryActiveLine = this.currentPrimaryActiveLine;
1645
- this.currentPrimaryActiveLine = primaryLine;
1646
- this.updatePositionClasses(primaryLine);
1786
+ if (newActiveLines.length > 0) {
1787
+ this.clearPreActiveClasses();
1647
1788
  }
1648
1789
  }
1790
+ this.startAnimationFromTime(newTime);
1649
1791
  // Trigger scroll imperatively (was previously in updated() via @state)
1650
- this._handleActiveLineScroll(oldActiveLines);
1792
+ this._handleActiveLineScroll(oldActiveLines, isSeek);
1651
1793
  }
1652
1794
  // YouLyPlus-style syllable animation updates
1653
1795
  if (this.lyricsContainer) {
@@ -1672,7 +1814,7 @@ let AmLyrics$1 = class AmLyrics extends i {
1672
1814
  const isActive = gap.classList.contains('active');
1673
1815
  const isExiting = gap.classList.contains('gap-exiting');
1674
1816
  // Start exit animation early so it completes before the next lyric
1675
- const exitLeadMs = 600;
1817
+ const exitLeadMs = GAP_EXIT_LEAD_MS;
1676
1818
  const shouldStartExiting = isActive && !isExiting && newTime >= gapEndTime - exitLeadMs;
1677
1819
  if (shouldBeActive && !isActive && !isExiting) {
1678
1820
  // Entering gap: remove any leftover exit state, add active
@@ -1697,13 +1839,6 @@ let AmLyrics$1 = class AmLyrics extends i {
1697
1839
  AmLyrics.updateSyllableAnimation(dot);
1698
1840
  }
1699
1841
  });
1700
- // Scroll to the gap element so dots animate in with
1701
- // the staggered scroll rather than popping in.
1702
- if (this.autoScroll &&
1703
- !this.isUserScrolling &&
1704
- !this.isClickSeeking) {
1705
- this.scrollToActiveLineYouLy(gap);
1706
- }
1707
1842
  }
1708
1843
  else if (shouldStartExiting) {
1709
1844
  // Exiting gap: keep visible while dots animate out
@@ -1712,7 +1847,7 @@ let AmLyrics$1 = class AmLyrics extends i {
1712
1847
  // After exit animation completes, remove gap-exiting to collapse
1713
1848
  setTimeout(() => {
1714
1849
  gap.classList.remove('gap-exiting');
1715
- }, 800);
1850
+ }, GAP_EXIT_LEAD_MS);
1716
1851
  }
1717
1852
  else if (isActive && !shouldBeActive) {
1718
1853
  // NEW: Immediate cleanup if we seeked out of valid range
@@ -1732,48 +1867,32 @@ let AmLyrics$1 = class AmLyrics extends i {
1732
1867
  else if (this.lastInstrumentalIndex !== null) {
1733
1868
  this.lastInstrumentalIndex = null;
1734
1869
  }
1735
- // Update position classes for YouLyPlus blur/opacity effect
1736
- // (only needed when lines didn't change — when they DID change,
1737
- // position classes are already updated above before scrolling)
1738
- if (!linesChanged && this.activeLineIndices.length > 0) {
1739
- const primaryLineIndex = this.activeLineIndices[0];
1740
- const primaryLine = this.lyricsContainer.querySelector(`#lyrics-line-${primaryLineIndex}`);
1741
- if (primaryLine && primaryLine !== this.currentPrimaryActiveLine) {
1742
- this.lastPrimaryActiveLine = this.currentPrimaryActiveLine;
1743
- this.currentPrimaryActiveLine = primaryLine;
1744
- this.updatePositionClasses(primaryLine);
1745
- }
1746
- }
1747
1870
  // Pre-scroll: scroll to upcoming line ~0.5s before it starts
1748
1871
  if (this.autoScroll &&
1749
1872
  !this.isUserScrolling &&
1750
1873
  !this.isClickSeeking &&
1751
1874
  this.lyrics) {
1752
- const preScrollLeadMs = 500; // 500ms lead time
1753
1875
  // Condition: ONLY pre-scroll if no other lyric is currently playing.
1754
1876
  // If a lyric is playing, we must wait for it to finish (handled by updated()).
1755
1877
  if (this.activeLineIndices.length === 0) {
1878
+ let preActiveLineIndex = null;
1756
1879
  for (let i = 0; i < this.lyrics.length; i += 1) {
1757
1880
  const line = this.lyrics[i];
1758
1881
  const timeUntilStart = line.timestamp - newTime;
1759
1882
  const nextLineEl = this.lyricsContainer.querySelector(`#lyrics-line-${i}`);
1760
- if (timeUntilStart > 0 && timeUntilStart <= preScrollLeadMs) {
1883
+ if (timeUntilStart > 0 && timeUntilStart <= PRE_SCROLL_LEAD_MS) {
1761
1884
  // Time to pre-scroll and pre-activate!
1762
1885
  if (nextLineEl) {
1763
1886
  // Apply unblur & zoom effect ahead of lyric start
1887
+ preActiveLineIndex = i;
1764
1888
  nextLineEl.classList.add('pre-active');
1765
- // Only trigger scroll if we aren't already targeting this line
1766
- if (nextLineEl !== this.currentPrimaryActiveLine) {
1767
- this.scrollToActiveLineYouLy(nextLineEl);
1768
- }
1889
+ this.clearPreActiveClasses(i);
1890
+ this.focusLine(nextLineEl);
1769
1891
  }
1770
1892
  break;
1771
1893
  }
1772
- else if (nextLineEl) {
1773
- // Ensure lines outside the pre-scroll window don't stay pre-active
1774
- nextLineEl.classList.remove('pre-active');
1775
- }
1776
1894
  }
1895
+ this.clearPreActiveClasses(preActiveLineIndex);
1777
1896
  }
1778
1897
  }
1779
1898
  }
@@ -1837,32 +1956,16 @@ let AmLyrics$1 = class AmLyrics extends i {
1837
1956
  * Handle scrolling when active line indices change.
1838
1957
  * Called imperatively from _onTimeChanged instead of from updated().
1839
1958
  */
1840
- _handleActiveLineScroll(oldActiveIndices) {
1841
- if (!this.autoScroll ||
1842
- this.isUserScrolling ||
1843
- this.isClickSeeking ||
1844
- this.activeLineIndices.length === 0) {
1959
+ _handleActiveLineScroll(_oldActiveIndices, forceScroll = false) {
1960
+ if (this.activeLineIndices.length === 0 || !this.lyricsContainer) {
1845
1961
  return;
1846
1962
  }
1847
- // Determine what changed: did we gain new lines or just lose old ones?
1848
- const newlyAdded = this.activeLineIndices.filter(idx => !oldActiveIndices.includes(idx));
1849
- if (newlyAdded.length === 0) {
1850
- // Only lost lines (an overlap resolved) — don't scroll
1963
+ const targetLineIndex = this.getPrimaryActiveLineIndex(this.activeLineIndices);
1964
+ if (targetLineIndex === null)
1851
1965
  return;
1852
- }
1853
- // New lines were added — scroll to the latest newly-added line.
1854
- // Previous overlap logic skipped every other line for songs with tiny
1855
- // timing overlaps between consecutive lines, causing a visible glitch.
1856
- const latestNewIndex = newlyAdded[newlyAdded.length - 1];
1857
- const targetLine = this.lyricsContainer?.querySelector(`#lyrics-line-${latestNewIndex}`);
1966
+ const targetLine = this.lyricsContainer.querySelector(`#lyrics-line-${targetLineIndex}`);
1858
1967
  if (targetLine) {
1859
- this.scrollToActiveLineYouLy(targetLine);
1860
- }
1861
- else if (this.currentPrimaryActiveLine) {
1862
- this.scrollToActiveLineYouLy(this.currentPrimaryActiveLine);
1863
- }
1864
- else {
1865
- this.scrollToActiveLine();
1968
+ this.focusLine(targetLine, forceScroll);
1866
1969
  }
1867
1970
  }
1868
1971
  _getTextWidth(text, font) {
@@ -1936,6 +2039,62 @@ let AmLyrics$1 = class AmLyrics extends i {
1936
2039
  static arraysEqual(a, b) {
1937
2040
  return a.length === b.length && a.every((val, i) => val === b[i]);
1938
2041
  }
2042
+ static getLineIndexFromElement(lineElement) {
2043
+ if (!lineElement)
2044
+ return null;
2045
+ const match = lineElement.id.match(/^lyrics-line-(\d+)$/);
2046
+ return match ? parseInt(match[1], 10) : null;
2047
+ }
2048
+ static getGapLoopDelay(gapDuration) {
2049
+ const desiredPhase = GAP_PULSE_DURATION_MS;
2050
+ const targetTime = gapDuration - GAP_EXIT_LEAD_MS;
2051
+ const normalizedTarget = ((targetTime % GAP_PULSE_CYCLE_MS) + GAP_PULSE_CYCLE_MS) %
2052
+ GAP_PULSE_CYCLE_MS;
2053
+ return ((((desiredPhase - normalizedTarget) % GAP_PULSE_CYCLE_MS) +
2054
+ GAP_PULSE_CYCLE_MS) %
2055
+ GAP_PULSE_CYCLE_MS);
2056
+ }
2057
+ clearPreActiveClasses(exceptLineIndex = null) {
2058
+ if (!this.lyricsContainer)
2059
+ return;
2060
+ this.lyricsContainer
2061
+ .querySelectorAll('.lyrics-line.pre-active')
2062
+ .forEach(element => {
2063
+ const lineElement = element;
2064
+ const lineIndex = AmLyrics.getLineIndexFromElement(lineElement);
2065
+ if (lineIndex !== exceptLineIndex) {
2066
+ lineElement.classList.remove('pre-active');
2067
+ }
2068
+ });
2069
+ }
2070
+ getPrimaryActiveLineIndex(activeIndices) {
2071
+ if (activeIndices.length === 0)
2072
+ return null;
2073
+ const groupStart = activeIndices[0];
2074
+ const groupEnd = activeIndices[activeIndices.length - 1];
2075
+ let candidateIndex = Math.max(groupStart, groupEnd - 2);
2076
+ const currentPrimaryIndex = AmLyrics.getLineIndexFromElement(this.currentPrimaryActiveLine);
2077
+ if (currentPrimaryIndex !== null &&
2078
+ activeIndices.includes(currentPrimaryIndex) &&
2079
+ candidateIndex < currentPrimaryIndex) {
2080
+ candidateIndex = currentPrimaryIndex;
2081
+ }
2082
+ return candidateIndex;
2083
+ }
2084
+ focusLine(lineElement, forceScroll = false) {
2085
+ const primaryChanged = lineElement !== this.currentPrimaryActiveLine;
2086
+ if (primaryChanged) {
2087
+ this.lastPrimaryActiveLine = this.currentPrimaryActiveLine;
2088
+ this.currentPrimaryActiveLine = lineElement;
2089
+ }
2090
+ this.updatePositionClasses(lineElement);
2091
+ if ((forceScroll || primaryChanged) &&
2092
+ this.autoScroll &&
2093
+ !this.isUserScrolling &&
2094
+ !this.isClickSeeking) {
2095
+ this.scrollToActiveLineYouLy(lineElement, forceScroll);
2096
+ }
2097
+ }
1939
2098
  handleUserScroll() {
1940
2099
  // Ignore programmatic scrolls and click-seek scrolls
1941
2100
  if (this.isProgrammaticScroll || this.isClickSeeking) {
@@ -2205,7 +2364,8 @@ let AmLyrics$1 = class AmLyrics extends i {
2205
2364
  this.activeLineIds.clear();
2206
2365
  this.animatingLines = [];
2207
2366
  // Find the clicked line element and scroll to it with forceScroll (like YouLyPlus)
2208
- const clickedLineElement = this.lyricsContainer?.querySelector(`.lyrics-line[data-start-time="${line.timestamp * 1000}"]`);
2367
+ // Timestamps are already in milliseconds — match the data-start-time attribute directly
2368
+ const clickedLineElement = this.lyricsContainer?.querySelector(`.lyrics-line[data-start-time="${line.text[0]?.timestamp || 0}"]`);
2209
2369
  if (clickedLineElement && this.lyricsContainer) {
2210
2370
  // Update active line reference to the clicked line
2211
2371
  this.currentPrimaryActiveLine = clickedLineElement;
@@ -2331,11 +2491,10 @@ let AmLyrics$1 = class AmLyrics extends i {
2331
2491
  }
2332
2492
  const { animatingLines } = this;
2333
2493
  const targetTop = Math.max(0, -newTranslateY);
2334
- // Always use actual scroll position - don't fall back to stale currentScrollOffset
2335
- // The || operator treats 0 as falsy, which caused bounce when scrollTop was 0
2494
+ const appliedTranslateY = -targetTop;
2336
2495
  const prevOffset = -parent.scrollTop;
2337
- const delta = prevOffset - newTranslateY;
2338
- this.currentScrollOffset = newTranslateY;
2496
+ const delta = prevOffset - appliedTranslateY;
2497
+ this.currentScrollOffset = appliedTranslateY;
2339
2498
  // Skip animation if already at the target position (e.g., first lines at top)
2340
2499
  if (Math.abs(parent.scrollTop - targetTop) < 1 && Math.abs(delta) < 1) {
2341
2500
  animState.isAnimating = false;
@@ -2348,6 +2507,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2348
2507
  line.classList.remove('scroll-animate');
2349
2508
  line.style.removeProperty('--scroll-delta');
2350
2509
  line.style.removeProperty('--lyrics-line-delay');
2510
+ line.style.removeProperty('--scroll-duration');
2351
2511
  }
2352
2512
  animatingLines.length = 0;
2353
2513
  parent.scrollTo({ top: targetTop, behavior: 'smooth' });
@@ -2371,7 +2531,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2371
2531
  const referenceIndex = lineArray.indexOf(referenceLine);
2372
2532
  if (referenceIndex === -1)
2373
2533
  return;
2374
- const delayIncrement = 30;
2534
+ const delayIncrement = SCROLL_DELAY_INCREMENT_MS;
2375
2535
  const lookBehind = 10;
2376
2536
  const lookAhead = 15;
2377
2537
  const len = lineArray.length;
@@ -2388,8 +2548,9 @@ let AmLyrics$1 = class AmLyrics extends i {
2388
2548
  const delay = i >= referenceIndex ? (delayCounter - 1) * delayIncrement : 0;
2389
2549
  line.style.setProperty('--scroll-delta', `${delta}px`);
2390
2550
  line.style.setProperty('--lyrics-line-delay', `${delay}ms`);
2551
+ line.style.setProperty('--scroll-duration', `${SCROLL_ANIMATION_DURATION_MS}ms`);
2391
2552
  newAnimatingLines.push(line);
2392
- const lineDuration = 400 + delay;
2553
+ const lineDuration = SCROLL_ANIMATION_DURATION_MS + delay;
2393
2554
  if (lineDuration > maxAnimationDuration) {
2394
2555
  maxAnimationDuration = lineDuration;
2395
2556
  }
@@ -2404,7 +2565,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2404
2565
  animatingLines.push(line);
2405
2566
  }
2406
2567
  animState.isAnimating = true;
2407
- const BASE_DURATION = 400;
2568
+ const BASE_DURATION = SCROLL_ANIMATION_DURATION_MS;
2408
2569
  this.scrollUnlockTimeout = setTimeout(() => {
2409
2570
  animState.isAnimating = false;
2410
2571
  if (animState.pendingUpdate !== null) {
@@ -2419,6 +2580,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2419
2580
  line.classList.remove('scroll-animate');
2420
2581
  line.style.removeProperty('--scroll-delta');
2421
2582
  line.style.removeProperty('--lyrics-line-delay');
2583
+ line.style.removeProperty('--scroll-duration');
2422
2584
  }
2423
2585
  animatingLines.length = 0;
2424
2586
  this.scrollAnimationTimeout = undefined;
@@ -2500,7 +2662,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2500
2662
  }
2501
2663
  setTimeout(() => {
2502
2664
  this.isProgrammaticScroll = false;
2503
- }, 600);
2665
+ }, SCROLL_ANIMATION_DURATION_MS + 160);
2504
2666
  this.animateScrollYouLy(targetTranslateY, forceScroll);
2505
2667
  }
2506
2668
  /**
@@ -2529,26 +2691,46 @@ let AmLyrics$1 = class AmLyrics extends i {
2529
2691
  // Use a Map to collect animations like YouLyPlus
2530
2692
  const charAnimationsMap = new Map();
2531
2693
  const styleUpdates = [];
2532
- // Step 1: Grow Pass - apply grow-dynamic to ALL word chars on first syllable
2694
+ // Step 1 & 2: Apply animations
2533
2695
  if (isGrowable && isFirstSyllable && allWordCharSpans.length > 0) {
2534
- const finalDuration = wordDurationMs;
2535
- const baseDelayPerChar = finalDuration * 0.09;
2536
- const growDurationMs = finalDuration * 1.5;
2537
- allWordCharSpans.forEach(span => {
2696
+ // Glow AND wipe applied to ALL characters simultaneously from the first syllable
2697
+ // This prevents CSS animation restarts because the `animation` property is set once.
2698
+ const firstSyllableStartTime = parseFloat(syllable.getAttribute('data-start-time') || '0');
2699
+ allWordCharSpans.forEach((span, charIndexInWord) => {
2538
2700
  const horizontalOffset = parseFloat(span.dataset.horizontalOffset || '0');
2539
- // Use syllableCharIndex like YouLyPlus, not loop index
2540
- const charIndex = parseFloat(span.dataset.syllableCharIndex || '0');
2541
- const growDelay = baseDelayPerChar * charIndex;
2542
- // READ DATA ATTRIBUTES for style values
2543
2701
  const maxScale = span.dataset.maxScale || '1.1';
2544
2702
  const shadowIntensity = span.dataset.shadowIntensity || '0.6';
2545
2703
  const translateYPeak = span.dataset.translateYPeak || '-2';
2546
- charAnimationsMap.set(span, `grow-dynamic ${growDurationMs}ms ease-in-out ${growDelay}ms forwards`);
2547
- // Push style updates to be applied imperatively
2704
+ const animationParts = [];
2705
+ const parentSyllable = span.closest('.lyrics-syllable');
2706
+ if (parentSyllable) {
2707
+ const parentDuration = parseFloat(parentSyllable.getAttribute('data-duration') || '0');
2708
+ const parentStartTime = parseFloat(parentSyllable.getAttribute('data-start-time') || '0');
2709
+ const startPct = parseFloat(span.dataset.wipeStart || '0');
2710
+ const durationPct = parseFloat(span.dataset.wipeDuration || '0');
2711
+ const relativeStartOffset = Math.max(0, parentStartTime - firstSyllableStartTime);
2712
+ const wipeDelay = relativeStartOffset + parentDuration * startPct;
2713
+ const wipeDuration = parentDuration * durationPct;
2714
+ const useStartAnimation = isFirstInContainer && charIndexInWord === 0;
2715
+ let charWipeAnimation = 'wipe';
2716
+ if (useStartAnimation)
2717
+ charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
2718
+ else
2719
+ charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
2720
+ // Blend word and syllable durations to let the gradient flow smoothly
2721
+ // while still responding to syllable pacing (no strict exactness, just natural flow)
2722
+ const growDelay = wipeDelay;
2723
+ const growDurationMs = Math.max(600, wordDurationMs * 0.8 + parentDuration * 1.5);
2724
+ animationParts.push(`grow-dynamic ${growDurationMs}ms ease-in-out ${growDelay}ms forwards`);
2725
+ if (wipeDuration > 0) {
2726
+ animationParts.push(`${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`);
2727
+ }
2728
+ }
2729
+ charAnimationsMap.set(span, animationParts.join(', '));
2548
2730
  styleUpdates.push({
2549
2731
  element: span,
2550
2732
  property: '--char-offset-x',
2551
- value: `${horizontalOffset}`, // Fixed: removed px suitable for matrix3d
2733
+ value: `${horizontalOffset}`,
2552
2734
  });
2553
2735
  styleUpdates.push({
2554
2736
  element: span,
@@ -2563,58 +2745,64 @@ let AmLyrics$1 = class AmLyrics extends i {
2563
2745
  styleUpdates.push({
2564
2746
  element: span,
2565
2747
  property: '--translate-y-peak',
2566
- value: `${translateYPeak}`, // Fixed: removed % because matrix3d expects raw number
2748
+ value: `${translateYPeak}`,
2567
2749
  });
2568
2750
  });
2569
2751
  }
2570
- // Step 2: Wipe Pass
2571
- if (charSpans.length > 0) {
2572
- // Per-character wipe for growable words (matching YouLyPlus)
2752
+ else if (isGrowable && !isFirstSyllable && charSpans.length > 0) {
2753
+ // For subsequent syllables of a growable word:
2754
+ // If they already have `grow-dynamic`, it means the first syllable correctly took care of BOTH grow and wipe!
2755
+ // Otherwise, they scrubbed directly into this syllable, so let's at least do the wipe.
2756
+ charSpans.forEach(span => {
2757
+ const existingAnimation = charAnimationsMap.get(span) || span.style.animation || '';
2758
+ if (existingAnimation.includes('grow-dynamic'))
2759
+ return;
2760
+ const startPct = parseFloat(span.dataset.wipeStart || '0');
2761
+ const durationPct = parseFloat(span.dataset.wipeDuration || '0');
2762
+ const wipeDelay = syllableDurationMs * startPct;
2763
+ const wipeDuration = syllableDurationMs * durationPct;
2764
+ const charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
2765
+ if (wipeDuration > 0) {
2766
+ charAnimationsMap.set(span, `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`);
2767
+ }
2768
+ });
2769
+ }
2770
+ else if (charSpans.length > 0) {
2771
+ // Per-character wipe for non-growable words (matching YouLyPlus)
2573
2772
  charSpans.forEach((span, charIndex) => {
2574
2773
  const startPct = parseFloat(span.dataset.wipeStart || '0');
2575
2774
  const durationPct = parseFloat(span.dataset.wipeDuration || '0');
2576
2775
  const wipeDelay = syllableDurationMs * startPct;
2577
2776
  const wipeDuration = syllableDurationMs * durationPct;
2578
2777
  const useStartAnimation = isFirstInContainer && charIndex === 0;
2579
- let charWipeAnimation;
2778
+ let charWipeAnimation = 'wipe';
2580
2779
  if (useStartAnimation) {
2581
2780
  charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
2582
2781
  }
2583
2782
  else {
2584
2783
  charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
2585
2784
  }
2586
- // Get existing animation from map (grow-dynamic) and combine with wipe
2587
- const existingAnimation = charAnimationsMap.get(span) || span.style.animation || '';
2588
- const animationParts = [];
2589
- if (existingAnimation && existingAnimation.includes('grow-dynamic')) {
2590
- animationParts.push(existingAnimation.split(',')[0].trim());
2591
- }
2592
2785
  if (wipeDuration > 0) {
2593
- animationParts.push(`${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`);
2786
+ charAnimationsMap.set(span, `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`);
2594
2787
  }
2595
- charAnimationsMap.set(span, animationParts.join(', '));
2596
2788
  });
2597
2789
  }
2598
2790
  else {
2599
- // Syllable-level wipe for regular (non-growable) words
2791
+ // Syllable-level wipe for regular (non-growable) words without chars
2600
2792
  const wipeRatio = parseFloat(syllable.getAttribute('data-wipe-ratio') || '1');
2601
2793
  const visualDuration = syllableDurationMs * wipeRatio;
2602
- let wipeAnimation;
2794
+ let wipeAnimation = 'wipe';
2603
2795
  if (isFirstInContainer) {
2604
2796
  wipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
2605
2797
  }
2606
2798
  else {
2607
2799
  wipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
2608
2800
  }
2609
- if (syllable.classList.contains('line-synced')) {
2610
- // If line-synced, just add the class for CSS animation, or ensure valid state
2611
- // The CSS rule .lyrics-syllable.line-synced handles the fade
2801
+ if (syllable.classList.contains('line-synced'))
2612
2802
  return;
2613
- }
2614
2803
  const currentWipeAnimation = isGap ? 'fade-gap' : wipeAnimation;
2615
- const syllableAnimation = `${currentWipeAnimation} ${visualDuration}ms ${isGap ? 'ease-out' : 'linear'} forwards`;
2616
2804
  // eslint-disable-next-line no-param-reassign
2617
- syllable.style.animation = syllableAnimation;
2805
+ syllable.style.animation = `${currentWipeAnimation} ${visualDuration}ms ${isGap ? 'ease-out' : 'linear'} forwards`;
2618
2806
  }
2619
2807
  // --- WRITE PHASE ---
2620
2808
  classList.remove('pre-highlight');
@@ -2834,7 +3022,7 @@ let AmLyrics$1 = class AmLyrics extends i {
2834
3022
  }
2835
3023
  }
2836
3024
  if (running) {
2837
- this.animationFrameId = requestAnimationFrame(this.animateProgress.bind(this));
3025
+ this.animationFrameId = requestAnimationFrame(this._boundAnimateProgress);
2838
3026
  }
2839
3027
  else if (this.animationFrameId) {
2840
3028
  // Stop animation if no words are running
@@ -3069,6 +3257,7 @@ let AmLyrics$1 = class AmLyrics extends i {
3069
3257
  // the result to each individual group so it renders through the
3070
3258
  // single-syllable path (which supports char-level glow).
3071
3259
  const groupGrowable = new Array(wordGroups.length).fill(false);
3260
+ const groupGlowing = new Array(wordGroups.length).fill(false);
3072
3261
  // Visual word info for growable char-level glow:
3073
3262
  // Each group stores the combined visual word's text, duration, and
3074
3263
  // the char offset of this group within the visual word.
@@ -3101,18 +3290,26 @@ let AmLyrics$1 = class AmLyrics extends i {
3101
3290
  const isCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(combinedText);
3102
3291
  const isRTL = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF]/.test(combinedText);
3103
3292
  const hasHyphen = combinedText.includes('-');
3104
- const isGrowableVW = !isCJK &&
3105
- !isRTL &&
3106
- !hasHyphen &&
3107
- combinedText.length <= 7 &&
3108
- combinedText.length > 0 &&
3109
- combinedDuration >= 900 &&
3110
- combinedDuration >= combinedText.length * 300 &&
3111
- (combinedText.length >= 4 ||
3112
- combinedDuration / combinedText.length >= 600);
3293
+ const wordLen = combinedText.length;
3294
+ let isGrowableVW = !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 12;
3295
+ if (isGrowableVW) {
3296
+ if (wordLen < 3) {
3297
+ isGrowableVW =
3298
+ combinedDuration >= 1110 && combinedDuration >= wordLen * 550;
3299
+ }
3300
+ else {
3301
+ isGrowableVW =
3302
+ combinedDuration >= 800 && combinedDuration >= wordLen * 180;
3303
+ }
3304
+ }
3305
+ // Glow requirement (more strict)
3306
+ const isGlowingVW = isGrowableVW &&
3307
+ combinedDuration >= 1200 &&
3308
+ combinedDuration >= combinedText.length * 300;
3113
3309
  let charOff = 0;
3114
3310
  for (let gi = vwStart; gi <= vwEnd; gi += 1) {
3115
3311
  groupGrowable[gi] = isGrowableVW;
3312
+ groupGlowing[gi] = isGlowingVW;
3116
3313
  vwFullText[gi] = combinedText;
3117
3314
  vwFullDuration[gi] = combinedDuration;
3118
3315
  vwCharOffset[gi] = charOff;
@@ -3128,224 +3325,126 @@ let AmLyrics$1 = class AmLyrics extends i {
3128
3325
  const mainVocalElement = b `<p class="main-vocal-container">
3129
3326
  ${wordGroups.map((group, groupIdx) => {
3130
3327
  const isGrowable = groupGrowable[groupIdx];
3131
- // For growable visual words spanning multiple groups:
3132
- // skip continuation groups (rendered by the first group)
3133
- if (isGrowable && vwCharOffset[groupIdx] > 0) {
3134
- return '';
3135
- }
3136
- // Check if ANY syllable in group is line-synced
3328
+ const isGlowing = groupGlowing[groupIdx];
3137
3329
  const groupLineSynced = group.some(s => s.lineSynced);
3138
- // For growable multi-group visual words, combine all text
3139
- // into one syllable so the wipe + glow animates as one unit
3140
- if (isGrowable && vwFullText[groupIdx].length > 0) {
3141
- const wordText = vwFullText[groupIdx];
3142
- const wordDuration = vwFullDuration[groupIdx];
3143
- const startTimeMs = vwStartMs[groupIdx];
3144
- const endTimeMs = vwEndMs[groupIdx];
3145
- const numChars = wordText.length;
3146
- // Collect all text from groups in this visual word
3147
- // by scanning forward while vwCharOffset is consecutive
3148
- let combinedRawText = '';
3149
- for (let gi = groupIdx; gi < wordGroups.length; gi += 1) {
3150
- if (gi > groupIdx && vwCharOffset[gi] === 0)
3151
- break;
3152
- if (gi > groupIdx && !groupGrowable[gi])
3153
- break;
3154
- combinedRawText += wordGroups[gi].map(s => s.text).join('');
3155
- }
3156
- const syllableContent = b `${combinedRawText
3157
- .split('')
3158
- .map((char, charIndex) => {
3159
- if (char === ' ') {
3160
- return ' ';
3161
- }
3162
- const charStartPercent = charIndex / numChars;
3163
- const minDuration = 1000;
3164
- const maxDuration = 5000;
3165
- const easingPower = 3;
3166
- const progress = Math.min(1, Math.max(0, (wordDuration - minDuration) /
3167
- (maxDuration - minDuration)));
3168
- const easedProgress = progress ** easingPower;
3169
- const isLongWord = numChars > 5;
3170
- const isShortDuration = wordDuration < 1500;
3171
- let maxDecayRate = 0;
3172
- if (isLongWord || isShortDuration) {
3173
- let decayStrength = 0;
3174
- if (isLongWord)
3175
- decayStrength += Math.min((numChars - 5) / 3, 1.0) * 0.4;
3176
- if (isShortDuration)
3177
- decayStrength +=
3178
- Math.max(0, 1.0 - (wordDuration - 1000) / 500) * 0.4;
3179
- maxDecayRate = Math.min(decayStrength, 0.85);
3180
- }
3181
- const positionInWord = numChars > 1 ? charIndex / (numChars - 1) : 0;
3182
- const decayFactor = 1.0 - positionInWord * maxDecayRate;
3183
- const charProgress = easedProgress * decayFactor;
3184
- const baseGrowth = numChars <= 3 ? 0.07 : 0.05;
3185
- const charMaxScale = 1.0 + baseGrowth + charProgress * 0.1;
3186
- const charShadowIntensity = 0.4 + charProgress * 0.4;
3187
- const normalizedGrowth = (charMaxScale - 1.0) / 0.13;
3188
- const charTranslateYPeak = -normalizedGrowth * 6;
3189
- const position = (charIndex + 0.5) / numChars;
3190
- const horizontalOffset = (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25);
3191
- return b `<span
3192
- class="char"
3193
- data-char-index="${charIndex}"
3194
- data-syllable-char-index="${charIndex}"
3195
- data-wipe-start="${charStartPercent.toFixed(4)}"
3196
- data-wipe-duration="${(1 / numChars).toFixed(4)}"
3197
- data-horizontal-offset="${horizontalOffset.toFixed(2)}"
3198
- data-max-scale="${charMaxScale.toFixed(3)}"
3199
- data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
3200
- data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
3201
- >${char}</span
3202
- >`;
3203
- })}`;
3204
- return b `<span class="lyrics-word growable">
3205
- <span class="lyrics-syllable-wrap">
3206
- <span
3207
- class="lyrics-syllable ${groupLineSynced
3208
- ? 'line-synced'
3209
- : ''}"
3210
- data-start-time="${startTimeMs}"
3211
- data-end-time="${endTimeMs}"
3212
- data-duration="${wordDuration}"
3213
- data-syllable-index="0"
3214
- data-wipe-ratio="1"
3215
- >${syllableContent}</span
3216
- >
3217
- </span>
3218
- </span>`;
3219
- }
3220
- // For single-syllable groups, use original logic
3221
- if (group.length === 1) {
3222
- const syllable = group[0];
3330
+ const wordText = isGrowable ? vwFullText[groupIdx] : '';
3331
+ const wordDuration = isGrowable ? vwFullDuration[groupIdx] : 0;
3332
+ const wordNumChars = wordText.length;
3333
+ const groupCharOffset = isGrowable ? vwCharOffset[groupIdx] : 0;
3334
+ let sylCharAccumulator = 0;
3335
+ return b `<span
3336
+ class="lyrics-word ${isGrowable ? 'growable' : ''} ${isGlowing
3337
+ ? 'glowing'
3338
+ : ''} ${group.length > 1 ? 'allow-break' : ''}"
3339
+ >
3340
+ ${group.map((syllable, sylIdx) => {
3223
3341
  const startTimeMs = syllable.timestamp;
3224
3342
  const endTimeMs = syllable.endtime;
3225
3343
  const durationMs = endTimeMs - startTimeMs;
3226
3344
  const text = syllable.text || '';
3227
- const trimmedText = text.trim();
3228
- // Optional romanization per syllable (hide if same as the original text)
3229
3345
  const romanizedText = this.showRomanization &&
3230
3346
  syllable.romanizedText &&
3231
3347
  syllable.romanizedText.trim() !== syllable.text.trim()
3232
3348
  ? b `<span
3233
- class="lyrics-syllable transliteration ${syllable.lineSynced
3349
+ class="lyrics-syllable transliteration ${groupLineSynced
3234
3350
  ? 'line-synced'
3235
3351
  : ''}"
3236
- data-start-time="${startTimeMs}"
3237
- data-end-time="${endTimeMs}"
3238
- data-duration="${durationMs}"
3239
- data-syllable-index="0"
3240
- data-wipe-ratio="1"
3241
- >${syllable.romanizedText}</span
3242
- >`
3352
+ data-start-time="${startTimeMs}"
3353
+ data-end-time="${endTimeMs}"
3354
+ data-duration="${durationMs}"
3355
+ data-syllable-index="0"
3356
+ data-wipe-ratio="1"
3357
+ >${syllable.romanizedText}</span
3358
+ >`
3243
3359
  : '';
3244
- // For growable words (single-group visual word), use char glow
3245
- const syllableContent = isGrowable
3246
- ? b `${text.split('').map((char, charIndex) => {
3247
- if (char === ' ') {
3360
+ let syllableContent = text;
3361
+ if (isGrowable) {
3362
+ let charIndexInsideSyllable = 0;
3363
+ const numCharsInSyllable = text.replace(/\s/g, '').length || 1;
3364
+ syllableContent = b `${text.split('').map(char => {
3365
+ if (char === ' ')
3248
3366
  return ' ';
3249
- }
3250
- const numChars = trimmedText.length;
3251
- const charStartPercent = charIndex / text.length;
3252
- const minDuration = 1000;
3253
- const maxDuration = 5000;
3367
+ const charIndexInsideWord = groupCharOffset + sylCharAccumulator;
3368
+ const charStartPercentVal = charIndexInsideSyllable / numCharsInSyllable;
3369
+ sylCharAccumulator += 1;
3370
+ charIndexInsideSyllable += 1;
3371
+ const minDuration = 400;
3372
+ const maxDuration = 3000;
3254
3373
  const easingPower = 3;
3255
- const progress = Math.min(1, Math.max(0, (durationMs - minDuration) /
3374
+ const progress = Math.min(1, Math.max(0, (wordDuration - minDuration) /
3256
3375
  (maxDuration - minDuration)));
3257
3376
  const easedProgress = progress ** easingPower;
3258
- const isLongWord = numChars > 5;
3259
- const isShortDuration = durationMs < 1500;
3377
+ const isLongWord = wordNumChars > 5;
3378
+ const isShortDuration = wordDuration < 1200;
3260
3379
  let maxDecayRate = 0;
3261
3380
  if (isLongWord || isShortDuration) {
3262
3381
  let decayStrength = 0;
3263
3382
  if (isLongWord)
3264
3383
  decayStrength +=
3265
- Math.min((numChars - 5) / 3, 1.0) * 0.4;
3266
- if (isShortDuration)
3384
+ Math.min((wordNumChars - 5) / 5, 1.0) * 0.4;
3385
+ if (isShortDuration && wordNumChars > 3)
3386
+ decayStrength +=
3387
+ Math.max(0, 1.0 - (wordDuration - 800) / 400) * 0.3;
3388
+ else if (isShortDuration && wordNumChars <= 3)
3267
3389
  decayStrength +=
3268
- Math.max(0, 1.0 - (durationMs - 1000) / 500) * 0.4;
3269
- maxDecayRate = Math.min(decayStrength, 0.85);
3390
+ Math.max(0, 1.0 - (wordDuration - 800) / 400) * 0.1;
3391
+ maxDecayRate = Math.min(decayStrength, 0.7);
3270
3392
  }
3271
- const positionInWord = numChars > 1 ? charIndex / (numChars - 1) : 0;
3393
+ const positionInWord = wordNumChars > 1
3394
+ ? charIndexInsideWord / (wordNumChars - 1)
3395
+ : 0;
3272
3396
  const decayFactor = 1.0 - positionInWord * maxDecayRate;
3273
3397
  const charProgress = easedProgress * decayFactor;
3274
- const baseGrowth = numChars <= 3 ? 0.07 : 0.05;
3275
- const charMaxScale = 1.0 + baseGrowth + charProgress * 0.1;
3276
- const charShadowIntensity = 0.4 + charProgress * 0.4;
3277
- const normalizedGrowth = (charMaxScale - 1.0) / 0.13;
3278
- const charTranslateYPeak = -normalizedGrowth * 6;
3279
- const position = (charIndex + 0.5) / numChars;
3398
+ const baseGrowth = wordNumChars <= 3 ? 0.05 : 0.04;
3399
+ const charMaxScale = 1.0 + baseGrowth + charProgress * 0.08;
3400
+ const glowDurFactor = Math.min(1.1, wordDuration / 1500);
3401
+ let glowLenFactor = 1.0;
3402
+ if (wordNumChars <= 3) {
3403
+ glowLenFactor = 0.85;
3404
+ }
3405
+ else if (wordNumChars >= 6) {
3406
+ glowLenFactor = 1.1;
3407
+ }
3408
+ const glowIntensityScale = glowDurFactor * glowLenFactor;
3409
+ const charShadowIntensity = isGlowing
3410
+ ? (0.35 + charProgress * 0.45) * glowIntensityScale
3411
+ : 0;
3412
+ const normalizedGrowth = (charMaxScale - 1.0) / 0.1;
3413
+ const effectiveDuration = (wordDuration + durationMs * 2) / 3;
3414
+ const peakMultiplier = Math.min(1, Math.max(0.3, effectiveDuration / 2000));
3415
+ const charTranslateYPeak = -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
3416
+ const position = (charIndexInsideWord + 0.5) / wordNumChars;
3280
3417
  const horizontalOffset = (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25);
3281
3418
  return b `<span
3282
3419
  class="char"
3283
- data-char-index="${charIndex}"
3284
- data-syllable-char-index="${charIndex}"
3285
- data-wipe-start="${charStartPercent.toFixed(4)}"
3286
- data-wipe-duration="${(1 / text.length).toFixed(4)}"
3420
+ data-char-index="${charIndexInsideWord}"
3421
+ data-syllable-char-index="${charIndexInsideWord}"
3422
+ data-wipe-start="${charStartPercentVal.toFixed(4)}"
3423
+ data-wipe-duration="${(1 / numCharsInSyllable).toFixed(4)}"
3287
3424
  data-horizontal-offset="${horizontalOffset.toFixed(2)}"
3288
3425
  data-max-scale="${charMaxScale.toFixed(3)}"
3289
3426
  data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
3290
3427
  data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
3291
3428
  >${char}</span
3292
3429
  >`;
3293
- })}`
3294
- : text;
3295
- return b `<span
3296
- class="lyrics-word ${isGrowable ? 'growable' : ''}"
3297
- >
3298
- <span class="lyrics-syllable-wrap">
3430
+ })}`;
3431
+ }
3432
+ return b `<span class="lyrics-syllable-wrap">
3299
3433
  <span
3300
- class="lyrics-syllable ${syllable.lineSynced
3434
+ class="lyrics-syllable ${groupLineSynced
3301
3435
  ? 'line-synced'
3302
3436
  : ''}"
3303
3437
  data-start-time="${startTimeMs}"
3304
3438
  data-end-time="${endTimeMs}"
3305
3439
  data-duration="${durationMs}"
3306
- data-syllable-index="0"
3440
+ data-word-duration="${wordDuration}"
3441
+ data-syllable-index="${sylIdx}"
3307
3442
  data-wipe-ratio="1"
3308
3443
  >${syllableContent}</span
3309
3444
  >
3310
3445
  ${romanizedText}
3311
- </span>
3312
- </span>`;
3313
- }
3314
- // Multi-syllable group (part=true): render all syllables inside one lyrics-word
3315
- return b `<span
3316
- class="lyrics-word ${isGrowable ? 'growable' : ''} allow-break"
3317
- >
3318
- ${group.map((syllable, sylIdx) => b `
3319
- <span class="lyrics-syllable-wrap">
3320
- <span
3321
- class="lyrics-syllable ${groupLineSynced
3322
- ? 'line-synced'
3323
- : ''}"
3324
- data-start-time="${syllable.timestamp}"
3325
- data-end-time="${syllable.endtime}"
3326
- data-duration="${syllable.endtime - syllable.timestamp}"
3327
- data-syllable-index="${sylIdx}"
3328
- data-wipe-ratio="1"
3329
- >${syllable.text}</span
3330
- >
3331
- ${this.showRomanization &&
3332
- syllable.romanizedText &&
3333
- syllable.romanizedText.trim() !== syllable.text.trim()
3334
- ? b `<span
3335
- class="lyrics-syllable transliteration ${groupLineSynced
3336
- ? 'line-synced'
3337
- : ''}"
3338
- data-start-time="${syllable.timestamp}"
3339
- data-end-time="${syllable.endtime}"
3340
- data-duration="${syllable.endtime -
3341
- syllable.timestamp}"
3342
- data-syllable-index="0"
3343
- data-wipe-ratio="1"
3344
- >${syllable.romanizedText}</span
3345
- >`
3346
- : ''}
3347
- </span>
3348
- `)}
3446
+ </span>`;
3447
+ })}
3349
3448
  </span>`;
3350
3449
  })}
3351
3450
  </p>`;
@@ -3376,14 +3475,17 @@ let AmLyrics$1 = class AmLyrics extends i {
3376
3475
  let maybeInstrumentalBlock = null;
3377
3476
  const gapForLine = gapByIndex.get(lineIndex);
3378
3477
  if (gapForLine) {
3478
+ const gapDuration = gapForLine.gapEnd - gapForLine.gapStart;
3379
3479
  // Calculate dot timing for fill-up animation (3 dots)
3380
- const dotDuration = (gapForLine.gapEnd - gapForLine.gapStart) / 3;
3480
+ const dotDuration = gapDuration / 3;
3481
+ const gapLoopDelay = AmLyrics.getGapLoopDelay(gapDuration);
3381
3482
  // Gap starts without 'active' — _onTimeChanged toggles it imperatively
3382
3483
  maybeInstrumentalBlock = b `<div
3383
3484
  id="gap-${lineIndex}"
3384
3485
  class="lyrics-line lyrics-gap"
3385
3486
  data-start-time="${gapForLine.gapStart}"
3386
3487
  data-end-time="${gapForLine.gapEnd}"
3488
+ style="--gap-pulse-duration: ${GAP_PULSE_DURATION_MS}ms; --gap-loop-delay: -${gapLoopDelay}ms; --gap-exit-duration: ${GAP_EXIT_LEAD_MS}ms; --gap-exit-scale: ${GAP_MIN_SCALE};"
3387
3489
  >
3388
3490
  <div class="lyrics-line-container">
3389
3491
  <p class="main-vocal-container">
@@ -3706,7 +3808,7 @@ AmLyrics$1.styles = i$3 `
3706
3808
  .lyrics-line.scroll-animate {
3707
3809
  transition: none !important; /* Prevent conflict with scroll animation */
3708
3810
  animation-name: lyrics-scroll;
3709
- animation-duration: 400ms;
3811
+ animation-duration: var(--scroll-duration, 280ms);
3710
3812
  animation-timing-function: cubic-bezier(0.41, 0, 0.12, 0.99);
3711
3813
  animation-fill-mode: both;
3712
3814
  animation-delay: var(--lyrics-line-delay, 0ms);
@@ -3778,19 +3880,20 @@ AmLyrics$1.styles = i$3 `
3778
3880
  opacity: 0;
3779
3881
  font-size: var(--lyplus-font-size-subtext);
3780
3882
  transition:
3781
- max-height 0.3s,
3782
- opacity 0.6s,
3783
- padding 0.6s;
3883
+ max-height 350ms cubic-bezier(0.33, 1, 0.68, 1),
3884
+ opacity 300ms ease-out,
3885
+ padding 350ms cubic-bezier(0.33, 1, 0.68, 1);
3784
3886
  margin: 0;
3785
3887
  }
3786
3888
 
3787
- .lyrics-line.active .background-vocal-container {
3889
+ .lyrics-line.active .background-vocal-container,
3890
+ .lyrics-line.pre-active .background-vocal-container {
3788
3891
  max-height: 4em;
3789
3892
  opacity: 1;
3790
3893
  transition:
3791
- max-height 0.6s,
3792
- opacity 0.6s,
3793
- padding 0.6s;
3894
+ max-height 350ms cubic-bezier(0.22, 1, 0.36, 1),
3895
+ opacity 300ms ease-out,
3896
+ padding 350ms cubic-bezier(0.22, 1, 0.36, 1);
3794
3897
  will-change: max-height, opacity, padding;
3795
3898
  }
3796
3899
 
@@ -3801,6 +3904,11 @@ AmLyrics$1.styles = i$3 `
3801
3904
  will-change: transform, opacity;
3802
3905
  }
3803
3906
 
3907
+ .lyrics-line.pre-active {
3908
+ opacity: 1;
3909
+ will-change: transform, opacity;
3910
+ }
3911
+
3804
3912
  .lyrics-line.singer-right {
3805
3913
  text-align: end;
3806
3914
  }
@@ -3880,7 +3988,7 @@ AmLyrics$1.styles = i$3 `
3880
3988
  /* Unblur early for pre-active lines */
3881
3989
  .lyrics-container.blur-inactive-enabled .lyrics-line.pre-active {
3882
3990
  filter: blur(0px) !important;
3883
- opacity: var(--lyplus-primary-opacity);
3991
+ opacity: 1;
3884
3992
  }
3885
3993
 
3886
3994
  /* ==========================================================================
@@ -4118,43 +4226,45 @@ AmLyrics$1.styles = i$3 `
4118
4226
  INSTRUMENTAL GAP STYLES
4119
4227
  ========================================================================== */
4120
4228
  .lyrics-gap {
4121
- height: 0;
4229
+ max-height: 0;
4122
4230
  padding: 0 var(--lyplus-padding-gap);
4123
4231
  overflow: hidden;
4124
4232
  opacity: 0;
4125
4233
  box-sizing: content-box;
4126
4234
  background-clip: unset;
4235
+ transform-origin: top;
4127
4236
  transition:
4128
- padding 0.3s 0.5s,
4129
- height 0.3s 0.5s,
4130
- opacity 0.2s 0.5s,
4131
- transform 0.3s var(--lyrics-line-delay, 0ms);
4237
+ padding 220ms cubic-bezier(0.33, 1, 0.68, 1),
4238
+ max-height 220ms cubic-bezier(0.33, 1, 0.68, 1),
4239
+ opacity 160ms ease-out,
4240
+ transform var(--scroll-duration, 280ms) var(--lyrics-line-delay, 0ms);
4132
4241
  }
4133
4242
 
4134
4243
  .lyrics-gap.active {
4135
- height: 1.3em;
4244
+ max-height: 1.6em;
4136
4245
  padding: var(--lyplus-padding-gap);
4137
4246
  opacity: 1;
4138
4247
  overflow: visible;
4139
4248
  transition:
4140
- padding 0.3s,
4141
- height 0.3s,
4142
- opacity 0.2s 0.3s,
4143
- transform 0.3s;
4144
- will-change: height, opacity, padding;
4249
+ padding 220ms cubic-bezier(0.22, 1, 0.36, 1),
4250
+ max-height 220ms cubic-bezier(0.22, 1, 0.36, 1),
4251
+ opacity 160ms ease-out,
4252
+ transform var(--scroll-duration, 280ms);
4253
+ will-change: max-height, opacity, padding;
4145
4254
  }
4146
4255
 
4147
- /* Exiting state: keep gap visible while dots animate out */
4256
+ /* Exiting state: quickly collapse width and height so dots don't distort page, or remove max-height transition */
4148
4257
  .lyrics-gap.gap-exiting {
4149
- height: 1.3em;
4150
- padding: var(--lyplus-padding-gap);
4151
- opacity: 1;
4258
+ max-height: 0;
4259
+ padding: 0 var(--lyplus-padding-gap);
4260
+ opacity: 0;
4152
4261
  overflow: visible;
4153
4262
  transition:
4154
- padding 0.3s 0.5s,
4155
- height 0.3s 0.5s,
4156
- opacity 0.2s 0.5s,
4157
- transform 0.3s;
4263
+ padding var(--gap-exit-duration, 360ms) cubic-bezier(0.33, 1, 0.68, 1),
4264
+ max-height var(--gap-exit-duration, 360ms)
4265
+ cubic-bezier(0.33, 1, 0.68, 1),
4266
+ opacity 160ms ease-out,
4267
+ transform var(--scroll-duration, 280ms);
4158
4268
  }
4159
4269
 
4160
4270
  .lyrics-gap .main-vocal-container {
@@ -4163,7 +4273,8 @@ AmLyrics$1.styles = i$3 `
4163
4273
 
4164
4274
  /* Jump animation plays during exit */
4165
4275
  .lyrics-gap.gap-exiting .main-vocal-container {
4166
- animation: gap-ended 0.8s ease forwards;
4276
+ animation: gap-ended var(--gap-exit-duration, 360ms)
4277
+ cubic-bezier(0.33, 1, 0.68, 1) forwards;
4167
4278
  }
4168
4279
 
4169
4280
  .lyrics-gap:not(.active):not(.gap-exiting) .main-vocal-container {
@@ -4177,7 +4288,9 @@ AmLyrics$1.styles = i$3 `
4177
4288
  }
4178
4289
 
4179
4290
  .lyrics-gap.active .main-vocal-container .lyrics-word {
4180
- animation: gap-loop 4s ease infinite alternate;
4291
+ animation: gap-loop var(--gap-pulse-duration, 4000ms) ease-in-out infinite
4292
+ alternate;
4293
+ animation-delay: var(--gap-loop-delay, 0ms);
4181
4294
  will-change: transform;
4182
4295
  }
4183
4296
 
@@ -4201,6 +4314,18 @@ AmLyrics$1.styles = i$3 `
4201
4314
  color: var(--lyplus-text-primary) !important;
4202
4315
  }
4203
4316
 
4317
+ .lyrics-line.pre-active .lyrics-syllable.line-synced {
4318
+ animation: fade-in-line 0.14s ease-out forwards !important;
4319
+ color: var(--lyplus-text-primary) !important;
4320
+ }
4321
+
4322
+ .lyrics-line.active .lyrics-syllable.line-synced span.char,
4323
+ .lyrics-line.pre-active .lyrics-syllable.line-synced span.char {
4324
+ background-image: none !important;
4325
+ background-color: var(--lyplus-text-primary) !important;
4326
+ transition: background-color 120ms ease-out !important;
4327
+ }
4328
+
4204
4329
  @keyframes fade-in-line {
4205
4330
  from {
4206
4331
  opacity: 0.5;
@@ -4614,19 +4739,20 @@ AmLyrics$1.styles = i$3 `
4614
4739
  /* Gap dot animations */
4615
4740
  @keyframes gap-loop {
4616
4741
  from {
4617
- transform: scale(1.15);
4742
+ transform: scale(1.12);
4618
4743
  }
4619
4744
  to {
4620
- transform: scale(0.85);
4745
+ transform: scale(var(--gap-exit-scale, 0.85));
4621
4746
  }
4622
4747
  }
4623
4748
 
4624
4749
  @keyframes gap-ended {
4625
4750
  0% {
4626
- transform: translateY(-25%) scale(1) translateZ(0);
4751
+ transform: translateY(-25%) scale(var(--gap-exit-scale, 0.85))
4752
+ translateZ(0);
4627
4753
  }
4628
4754
  35% {
4629
- transform: translateY(-25%) scale(1.2) translateZ(0);
4755
+ transform: translateY(-5%) scale(1.08) translateZ(0);
4630
4756
  }
4631
4757
  100% {
4632
4758
  transform: translateY(-25%) scale(0) translateZ(0);