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