@uimaxbai/am-lyrics 1.4.0 → 1.5.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.
@@ -319,14 +319,11 @@ class GoogleService {
319
319
  }
320
320
  }
321
321
 
322
- const VERSION = '1.4.0';
322
+ const VERSION = '1.5.0';
323
323
  const INSTRUMENTAL_THRESHOLD_MS = 7000; // Show dots for gaps >= 7s
324
324
  const FETCH_TIMEOUT_MS = 8000; // Timeout for all lyrics fetch requests
325
325
  const SEEK_THRESHOLD_MS = 500;
326
- const PRE_SCROLL_LEAD_MS = 500;
327
- const PRE_SCROLL_LEAD_SHORT_MS = 350;
328
- const SCROLL_ANIMATION_DURATION_MS = 280;
329
- const SCROLL_DELAY_INCREMENT_MS = 24;
326
+ const SCROLL_ANIMATION_DURATION_MS = 350;
330
327
  const GAP_PULSE_DURATION_MS = 4000;
331
328
  const GAP_PULSE_CYCLE_MS = GAP_PULSE_DURATION_MS * 2;
332
329
  const GAP_EXIT_LEAD_MS = 600;
@@ -377,6 +374,8 @@ class AmLyrics extends i {
377
374
  this.isClickSeeking = false;
378
375
  // Cached DOM elements for animation updates
379
376
  this.cachedLyricsLines = [];
377
+ // Cached line elements array for scroll/position queries
378
+ this.cachedLineArray = [];
380
379
  // Cached line and gap element maps for fast lookup
381
380
  this.lineElementCache = new Map();
382
381
  this.gapElementCache = new Map();
@@ -397,6 +396,8 @@ class AmLyrics extends i {
397
396
  // Syllable animation tracking
398
397
  this.lastActiveIndex = 0;
399
398
  this.visibleLineIds = new Set();
399
+ // Cached scroll padding top value
400
+ this.cachedScrollPaddingTop = null;
400
401
  // Cached element tracking to avoid repeated querySelectorAll calls
401
402
  this.preActiveLineElements = [];
402
403
  this.positionedLineElements = [];
@@ -487,9 +488,9 @@ class AmLyrics extends i {
487
488
  this.activeGapLineElements = [];
488
489
  // Stop all running animations and clear highlights immediately
489
490
  if (this.lyricsContainer) {
490
- const activeLines = this.lyricsContainer.querySelectorAll('.lyrics-line.active, .lyrics-line.pre-active');
491
+ const activeLines = this.lyricsContainer.querySelectorAll('.lyrics-line.active, .lyrics-line.pre-active, .lyrics-line.bg-expanded');
491
492
  activeLines.forEach(line => {
492
- line.classList.remove('active', 'pre-active');
493
+ line.classList.remove('active', 'pre-active', 'bg-expanded');
493
494
  AmLyrics.resetSyllables(line);
494
495
  });
495
496
  const activeGaps = this.lyricsContainer.querySelectorAll('.lyrics-gap.active, .lyrics-gap.gap-exiting');
@@ -543,14 +544,14 @@ class AmLyrics extends i {
543
544
  clearTimeout(this.clickSeekTimeout);
544
545
  this.clickSeekTimeout = undefined;
545
546
  }
546
- if (this.scrollUnlockTimeout) {
547
- clearTimeout(this.scrollUnlockTimeout);
548
- this.scrollUnlockTimeout = undefined;
549
- }
550
547
  if (this.scrollAnimationTimeout) {
551
548
  clearTimeout(this.scrollAnimationTimeout);
552
549
  this.scrollAnimationTimeout = undefined;
553
550
  }
551
+ if (this.scrollUnlockTimeout) {
552
+ clearTimeout(this.scrollUnlockTimeout);
553
+ this.scrollUnlockTimeout = undefined;
554
+ }
554
555
  // Cancel any in-flight fetch requests
555
556
  this.fetchAbortController?.abort();
556
557
  this.fetchAbortController = undefined;
@@ -562,6 +563,8 @@ class AmLyrics extends i {
562
563
  this.preActiveLineElements = [];
563
564
  this.positionedLineElements = [];
564
565
  this.activeGapLineElements = [];
566
+ this.visibilityObserver?.disconnect();
567
+ this.visibilityObserver = undefined;
565
568
  }
566
569
  async fetchLyrics() {
567
570
  // Cancel any in-flight fetch to prevent stale results from racing
@@ -590,9 +593,21 @@ class AmLyrics extends i {
590
593
  if (resolvedMetadata?.metadata && !isMusicIdOnlyRequest) {
591
594
  const title = resolvedMetadata.metadata.title?.trim() || '';
592
595
  const artist = resolvedMetadata.metadata.artist?.trim() || '';
593
- const youLyResults = await AmLyrics.fetchLyricsFromYouLyPlus(title, artist, resolvedMetadata.catalogIsrc, resolvedMetadata.metadata);
594
- if (youLyResults && youLyResults.length > 0) {
595
- collectedSources.push(...youLyResults);
596
+ const biniResult = await AmLyrics.fetchLyricsFromBiniLyrics(title, artist, resolvedMetadata.catalogIsrc, resolvedMetadata.metadata);
597
+ if (biniResult && biniResult.lines.length > 0) {
598
+ collectedSources.push(biniResult);
599
+ }
600
+ if (collectedSources.length === 0) {
601
+ const unisonResult = await AmLyrics.fetchLyricsFromUnison(resolvedMetadata.metadata);
602
+ if (unisonResult && unisonResult.lines.length > 0) {
603
+ collectedSources.push(unisonResult);
604
+ }
605
+ }
606
+ if (collectedSources.length === 0) {
607
+ const youLyResults = await AmLyrics.fetchLyricsFromYouLyPlus(title, artist, resolvedMetadata.catalogIsrc, resolvedMetadata.metadata, true);
608
+ if (youLyResults && youLyResults.length > 0) {
609
+ collectedSources.push(...youLyResults);
610
+ }
596
611
  }
597
612
  }
598
613
  if (collectedSources.length === 0 && resolvedMetadata?.metadata) {
@@ -676,35 +691,47 @@ class AmLyrics extends i {
676
691
  const isQQ = lower.includes('qq') || lower.includes('lyricsplus');
677
692
  if (lower.includes('apple') && hasWordSync)
678
693
  return 1;
679
- if (isQQ && hasWordSync)
694
+ if (lower.includes('bini') && hasWordSync)
680
695
  return 2;
681
- if (lower.includes('musixmatch') && hasWordSync)
696
+ if (lower.includes('unison') && hasWordSync)
682
697
  return 3;
683
- if (lower.includes('lrclib') && hasWordSync)
698
+ if (isQQ && hasWordSync)
684
699
  return 4;
685
- if (hasWordSync)
700
+ if (lower.includes('musixmatch') && hasWordSync)
686
701
  return 5;
687
- if (lower.includes('apple') && !hasWordSync && !isUnsynced)
702
+ if (lower.includes('lrclib') && hasWordSync)
688
703
  return 6;
689
- if (isQQ && !hasWordSync && !isUnsynced)
704
+ if (hasWordSync)
690
705
  return 7;
691
- if (lower.includes('musixmatch') && !hasWordSync && !isUnsynced)
706
+ if (lower.includes('apple') && !hasWordSync && !isUnsynced)
692
707
  return 8;
693
- if (lower.includes('lrclib') && !hasWordSync && !isUnsynced)
708
+ if (lower.includes('bini') && !hasWordSync && !isUnsynced)
694
709
  return 9;
695
- if (!hasWordSync && !isUnsynced)
710
+ if (lower.includes('unison') && !hasWordSync && !isUnsynced)
696
711
  return 10;
697
- if (lower.includes('apple') && isUnsynced)
712
+ if (isQQ && !hasWordSync && !isUnsynced)
698
713
  return 11;
699
- if (isQQ && isUnsynced)
714
+ if (lower.includes('musixmatch') && !hasWordSync && !isUnsynced)
700
715
  return 12;
701
- if (lower.includes('musixmatch') && isUnsynced)
716
+ if (lower.includes('lrclib') && !hasWordSync && !isUnsynced)
702
717
  return 13;
703
- if (lower.includes('lrclib') && isUnsynced)
718
+ if (!hasWordSync && !isUnsynced)
704
719
  return 14;
705
- if (lower.includes('genius'))
720
+ if (lower.includes('apple') && isUnsynced)
706
721
  return 15;
707
- return 20;
722
+ if (lower.includes('bini') && isUnsynced)
723
+ return 16;
724
+ if (lower.includes('unison') && isUnsynced)
725
+ return 17;
726
+ if (isQQ && isUnsynced)
727
+ return 18;
728
+ if (lower.includes('musixmatch') && isUnsynced)
729
+ return 19;
730
+ if (lower.includes('lrclib') && isUnsynced)
731
+ return 20;
732
+ if (lower.includes('genius'))
733
+ return 21;
734
+ return 30;
708
735
  }
709
736
  static mergeAndSortSources(collectedSources) {
710
737
  const uniqueSourcesMap = new Map();
@@ -922,60 +949,11 @@ class AmLyrics extends i {
922
949
  }
923
950
  return null;
924
951
  }
925
- static async fetchLyricsFromYouLyPlus(title, artist, isrc, metadata = {}) {
952
+ static async fetchLyricsFromBiniLyrics(title, artist, isrc, metadata = {}) {
926
953
  if ((!title || !artist) && !isrc)
927
- return [];
928
- const params = new URLSearchParams();
929
- if (title)
930
- params.append('title', title);
931
- if (artist)
932
- params.append('artist', artist);
933
- if (isrc)
934
- params.append('isrc', isrc);
935
- if (metadata.album) {
936
- params.append('album', metadata.album);
937
- }
938
- if (metadata.durationMs && metadata.durationMs > 0) {
939
- params.append('duration', Math.round(metadata.durationMs / 1000).toString());
940
- }
941
- if (!DEFAULT_KPOE_SOURCE_ORDER.includes('apple')) {
942
- params.append('source', DEFAULT_KPOE_SOURCE_ORDER);
943
- }
944
- const getRank = (sourceLabel, parsedLines) => {
945
- const lower = sourceLabel.toLowerCase();
946
- const hasWordSync = parsedLines.some((line) => line.text && Array.isArray(line.text) && line.text.length > 1);
947
- const isUnsynced = parsedLines.length > 0 &&
948
- parsedLines.every((line) => line.timestamp === 0 && line.endtime === 0);
949
- const isQQ = lower.includes('qq') || lower.includes('lyricsplus');
950
- if (lower.includes('apple') && hasWordSync)
951
- return 1;
952
- if (isQQ && hasWordSync)
953
- return 2;
954
- if (lower.includes('musixmatch') && hasWordSync)
955
- return 3;
956
- if (hasWordSync)
957
- return 4;
958
- if (lower.includes('apple') && !hasWordSync && !isUnsynced)
959
- return 5;
960
- if (isQQ && !hasWordSync && !isUnsynced)
961
- return 6;
962
- if (lower.includes('musixmatch') && !hasWordSync && !isUnsynced)
963
- return 7;
964
- if (!hasWordSync && !isUnsynced)
965
- return 8;
966
- if (lower.includes('apple') && isUnsynced)
967
- return 9;
968
- if (isQQ && isUnsynced)
969
- return 10;
970
- if (lower.includes('musixmatch') && isUnsynced)
971
- return 11;
972
- return 20;
973
- };
974
- const allResults = [];
975
- // Try BiniLyrics cache API first
954
+ return null;
976
955
  try {
977
956
  let cacheData = null;
978
- // First attempt: Prefer ISRC search if available
979
957
  if (isrc) {
980
958
  try {
981
959
  const isrcUrl = `https://lyrics-api.binimum.org/?isrc=${encodeURIComponent(isrc)}`;
@@ -987,11 +965,10 @@ class AmLyrics extends i {
987
965
  }
988
966
  }
989
967
  }
990
- catch (isrcErr) {
968
+ catch {
991
969
  // Fall through to title/artist search
992
970
  }
993
971
  }
994
- // Second attempt: Fallback to title and artist search if ISRC search failed or was not available
995
972
  if (!cacheData && title && artist) {
996
973
  const cacheParams = new URLSearchParams({
997
974
  track: title,
@@ -1011,57 +988,17 @@ class AmLyrics extends i {
1011
988
  }
1012
989
  if (cacheData && cacheData.results && cacheData.results.length > 0) {
1013
990
  const result = cacheData.results[0];
1014
- if (result.timing_type === 'word' && result.lyricsUrl) {
991
+ if (result.lyricsUrl) {
1015
992
  const ttmlRes = await fetchWithTimeout(result.lyricsUrl);
1016
993
  if (ttmlRes.ok) {
1017
994
  const ttmlText = await ttmlRes.text();
1018
995
  const parseResult = AmLyrics.parseTTML(ttmlText);
1019
996
  if (parseResult && parseResult.lines.length > 0) {
1020
- allResults.push({
997
+ return {
1021
998
  lines: parseResult.lines,
1022
999
  source: 'BiniLyrics',
1023
1000
  songwriters: parseResult.songwriters,
1024
- });
1025
- return allResults;
1026
- }
1027
- }
1028
- }
1029
- else {
1030
- // Not word type, try fetching any word synced lyrics from lyricsplus
1031
- const fallbackParams = new URLSearchParams(params);
1032
- const fallbackUrl = `https://lyricsplus.binimum.org/v2/lyrics/get?${fallbackParams.toString()}`;
1033
- try {
1034
- const fallbackRes = await fetchWithTimeout(fallbackUrl);
1035
- if (fallbackRes.ok) {
1036
- const payload = await fallbackRes.json();
1037
- const lines = AmLyrics.convertKPoeLyrics(payload);
1038
- const hasWordSync = lines?.some((line) => line.text && Array.isArray(line.text) && line.text.length > 1);
1039
- if (lines && lines.length > 0 && hasWordSync) {
1040
- const sourceLabel = payload?.metadata?.source ||
1041
- payload?.metadata?.provider ||
1042
- 'LyricsPlus (KPoe)';
1043
- allResults.push({ lines, source: sourceLabel });
1044
- return allResults;
1045
- }
1046
- }
1047
- }
1048
- catch (fallbackError) {
1049
- // Ignore fallback fetch error
1050
- }
1051
- // If fallback fails or has no word sync, fall back to bini lyrics
1052
- if (result.lyricsUrl) {
1053
- const ttmlRes = await fetchWithTimeout(result.lyricsUrl);
1054
- if (ttmlRes.ok) {
1055
- const ttmlText = await ttmlRes.text();
1056
- const parseResult = AmLyrics.parseTTML(ttmlText);
1057
- if (parseResult && parseResult.lines.length > 0) {
1058
- allResults.push({
1059
- lines: parseResult.lines,
1060
- source: 'BiniLyrics',
1061
- songwriters: parseResult.songwriters,
1062
- });
1063
- return allResults;
1064
- }
1001
+ };
1065
1002
  }
1066
1003
  }
1067
1004
  }
@@ -1071,6 +1008,77 @@ class AmLyrics extends i {
1071
1008
  // eslint-disable-next-line no-console
1072
1009
  console.error('Cache API failed', e);
1073
1010
  }
1011
+ return null;
1012
+ }
1013
+ static async fetchLyricsFromYouLyPlus(title, artist, isrc, metadata = {}, skipBiniCache = false) {
1014
+ if ((!title || !artist) && !isrc)
1015
+ return [];
1016
+ const params = new URLSearchParams();
1017
+ if (title)
1018
+ params.append('title', title);
1019
+ if (artist)
1020
+ params.append('artist', artist);
1021
+ if (isrc)
1022
+ params.append('isrc', isrc);
1023
+ if (metadata.album) {
1024
+ params.append('album', metadata.album);
1025
+ }
1026
+ if (metadata.durationMs && metadata.durationMs > 0) {
1027
+ params.append('duration', Math.round(metadata.durationMs / 1000).toString());
1028
+ }
1029
+ if (!DEFAULT_KPOE_SOURCE_ORDER.includes('apple')) {
1030
+ params.append('source', DEFAULT_KPOE_SOURCE_ORDER);
1031
+ }
1032
+ const getRank = (sourceLabel, parsedLines) => {
1033
+ const lower = sourceLabel.toLowerCase();
1034
+ const hasWordSync = parsedLines.some((line) => line.text && Array.isArray(line.text) && line.text.length > 1);
1035
+ const isUnsynced = parsedLines.length > 0 &&
1036
+ parsedLines.every((line) => line.timestamp === 0 && line.endtime === 0);
1037
+ const isQQ = lower.includes('qq') || lower.includes('lyricsplus');
1038
+ if (lower.includes('apple') && hasWordSync)
1039
+ return 1;
1040
+ if (lower.includes('bini') && hasWordSync)
1041
+ return 2;
1042
+ if (lower.includes('unison') && hasWordSync)
1043
+ return 3;
1044
+ if (isQQ && hasWordSync)
1045
+ return 4;
1046
+ if (lower.includes('musixmatch') && hasWordSync)
1047
+ return 5;
1048
+ if (hasWordSync)
1049
+ return 6;
1050
+ if (lower.includes('apple') && !hasWordSync && !isUnsynced)
1051
+ return 7;
1052
+ if (lower.includes('bini') && !hasWordSync && !isUnsynced)
1053
+ return 8;
1054
+ if (lower.includes('unison') && !hasWordSync && !isUnsynced)
1055
+ return 9;
1056
+ if (isQQ && !hasWordSync && !isUnsynced)
1057
+ return 10;
1058
+ if (lower.includes('musixmatch') && !hasWordSync && !isUnsynced)
1059
+ return 11;
1060
+ if (!hasWordSync && !isUnsynced)
1061
+ return 12;
1062
+ if (lower.includes('apple') && isUnsynced)
1063
+ return 13;
1064
+ if (lower.includes('bini') && isUnsynced)
1065
+ return 14;
1066
+ if (lower.includes('unison') && isUnsynced)
1067
+ return 15;
1068
+ if (isQQ && isUnsynced)
1069
+ return 16;
1070
+ if (lower.includes('musixmatch') && isUnsynced)
1071
+ return 17;
1072
+ return 30;
1073
+ };
1074
+ const allResults = [];
1075
+ if (!skipBiniCache) {
1076
+ const biniResult = await AmLyrics.fetchLyricsFromBiniLyrics(title, artist, isrc, metadata);
1077
+ if (biniResult) {
1078
+ allResults.push(biniResult);
1079
+ return allResults;
1080
+ }
1081
+ }
1074
1082
  // Shuffle servers so we pick a random one first, with all others as fallback
1075
1083
  // Try up to 3 servers to improve reliability when some have CORS or connectivity issues
1076
1084
  const shuffledServers = [...KPOE_SERVERS]
@@ -1296,6 +1304,73 @@ class AmLyrics extends i {
1296
1304
  }
1297
1305
  return null;
1298
1306
  }
1307
+ static async fetchLyricsFromUnison(metadata) {
1308
+ const title = metadata.title?.trim();
1309
+ const artist = metadata.artist?.trim();
1310
+ if (!title || !artist)
1311
+ return null;
1312
+ const params = new URLSearchParams();
1313
+ params.append('song', title);
1314
+ params.append('artist', artist);
1315
+ if (metadata.album) {
1316
+ params.append('album', metadata.album);
1317
+ }
1318
+ if (metadata.durationMs && metadata.durationMs > 0) {
1319
+ params.append('duration', Math.round(metadata.durationMs / 1000).toString());
1320
+ }
1321
+ try {
1322
+ const response = await fetchWithTimeout(`https://unison.boidu.dev/lyrics?${params.toString()}`);
1323
+ if (!response.ok)
1324
+ return null;
1325
+ const data = await response.json();
1326
+ if (!data.success || !data.data?.lyrics)
1327
+ return null;
1328
+ const lyricsData = data.data;
1329
+ const format = lyricsData.format || 'lrc';
1330
+ const syncType = lyricsData.syncType || 'linesync';
1331
+ const lyricsText = lyricsData.lyrics;
1332
+ if (format === 'ttml') {
1333
+ const parseResult = AmLyrics.parseTTML(lyricsText);
1334
+ if (parseResult && parseResult.lines.length > 0) {
1335
+ return {
1336
+ lines: parseResult.lines,
1337
+ source: 'Unison',
1338
+ songwriters: parseResult.songwriters,
1339
+ };
1340
+ }
1341
+ }
1342
+ if (format === 'lrc') {
1343
+ if (syncType === 'plain') {
1344
+ const plainLines = lyricsText
1345
+ .split('\n')
1346
+ .map((l) => l.trim())
1347
+ .filter((l) => l);
1348
+ if (plainLines.length > 0) {
1349
+ const lines = plainLines.map((text) => ({
1350
+ text: [{ text, part: false, timestamp: 0, endtime: 0 }],
1351
+ background: false,
1352
+ backgroundText: [],
1353
+ oppositeTurn: false,
1354
+ timestamp: 0,
1355
+ endtime: 0,
1356
+ isWordSynced: false,
1357
+ }));
1358
+ return { lines, source: 'Unison (unsynced)' };
1359
+ }
1360
+ }
1361
+ else {
1362
+ const lines = AmLyrics.parseLrcSubtitles(lyricsText);
1363
+ if (lines.length > 0) {
1364
+ return { lines, source: 'Unison' };
1365
+ }
1366
+ }
1367
+ }
1368
+ }
1369
+ catch {
1370
+ // Unison fetch failed
1371
+ }
1372
+ return null;
1373
+ }
1299
1374
  static calculateLineAlignments(lineSingers, agentTypes) {
1300
1375
  const lineSideAssignments = new Array(lineSingers.length).fill(undefined);
1301
1376
  let currentSideIsLeft = true;
@@ -1771,25 +1846,35 @@ class AmLyrics extends i {
1771
1846
  const linesChanged = !AmLyrics.arraysEqual(newActiveLines, oldActiveLines);
1772
1847
  if (linesChanged || isSeek) {
1773
1848
  if (this.lyricsContainer) {
1774
- // Remove 'active' from lines that are no longer active
1849
+ // Remove .active and .bg-expanded immediately when a line drops.
1850
+ // All visual fading is handled by CSS transitions — no JS delays,
1851
+ // so overlapping lyrics never get stuck with multiple .active lines.
1775
1852
  for (const lineIndex of oldActiveLines) {
1776
1853
  if (!newActiveLines.includes(lineIndex)) {
1777
1854
  const lineElement = this._getLineElement(lineIndex);
1778
1855
  if (lineElement) {
1779
- lineElement.classList.remove('active', 'pre-active');
1856
+ if (isSeek || this.isUserScrolling) {
1857
+ AmLyrics.unfinishSyllables(lineElement);
1858
+ }
1859
+ else {
1860
+ AmLyrics.finishSyllablesUpToTime(lineElement, newTime);
1861
+ }
1862
+ lineElement.classList.remove('active', 'bg-expanded');
1863
+ if (lineElement.classList.contains('pre-active')) {
1864
+ lineElement.classList.remove('pre-active');
1865
+ }
1780
1866
  const preIdx = this.preActiveLineElements.indexOf(lineElement);
1781
1867
  if (preIdx !== -1)
1782
1868
  this.preActiveLineElements.splice(preIdx, 1);
1783
- AmLyrics.resetSyllables(lineElement);
1784
1869
  }
1785
1870
  }
1786
1871
  }
1787
- // Add 'active' to newly active lines
1872
+ // Add 'active' and 'bg-expanded' to newly active lines
1788
1873
  for (const lineIndex of newActiveLines) {
1789
1874
  if (!oldActiveLines.includes(lineIndex)) {
1790
1875
  const lineElement = this._getLineElement(lineIndex);
1791
1876
  if (lineElement) {
1792
- lineElement.classList.add('active');
1877
+ lineElement.classList.add('active', 'bg-expanded');
1793
1878
  lineElement.classList.remove('pre-active');
1794
1879
  const preIdx = this.preActiveLineElements.indexOf(lineElement);
1795
1880
  if (preIdx !== -1)
@@ -1797,13 +1882,24 @@ class AmLyrics extends i {
1797
1882
  }
1798
1883
  }
1799
1884
  }
1800
- if (newActiveLines.length > 0) {
1801
- this.clearPreActiveClasses();
1885
+ // Remove pre-active from lines that are now active (they no longer
1886
+ // need the unblur preview class) and from lines that dropped.
1887
+ for (const lineElement of this.preActiveLineElements) {
1888
+ const idx = AmLyrics.getLineIndexFromElement(lineElement);
1889
+ if (idx === null ||
1890
+ (!newActiveLines.includes(idx) &&
1891
+ lineElement !== this.currentPrimaryActiveLine)) {
1892
+ lineElement.classList.remove('pre-active');
1893
+ }
1802
1894
  }
1895
+ this.preActiveLineElements = this.preActiveLineElements.filter(el => el.classList.contains('pre-active'));
1803
1896
  }
1804
1897
  this.startAnimationFromTime(newTime);
1805
- this._handleActiveLineScroll(oldActiveLines, isSeek);
1806
1898
  }
1899
+ // Predictive scroll: run on every tick so we scroll *before* the next
1900
+ // line starts, matching YouLyPlus behaviour.
1901
+ this._handleActiveLineScroll(oldActiveLines, isSeek);
1902
+ this.clearPastLineHighlights();
1807
1903
  if (this.lyricsContainer) {
1808
1904
  // Update syllables in active lines using cached elements
1809
1905
  for (const lineIndex of this.activeLineIndices) {
@@ -1819,8 +1915,10 @@ class AmLyrics extends i {
1819
1915
  // Imperatively manage gap active state
1820
1916
  if (this.gapElementCache.size > 0) {
1821
1917
  for (const [, gap] of this.gapElementCache) {
1822
- const gapStartTime = parseFloat(gap.getAttribute('data-start-time') || '0');
1823
- const gapEndTime = parseFloat(gap.getAttribute('data-end-time') || '0');
1918
+ const gapStartTime = gap._cachedStartTime ??
1919
+ parseFloat(gap.getAttribute('data-start-time') || '0');
1920
+ const gapEndTime = gap._cachedEndTime ??
1921
+ parseFloat(gap.getAttribute('data-end-time') || '0');
1824
1922
  const shouldBeActive = newTime >= gapStartTime && newTime < gapEndTime;
1825
1923
  const isActive = gap.classList.contains('active');
1826
1924
  const isExiting = gap.classList.contains('gap-exiting');
@@ -1951,6 +2049,15 @@ class AmLyrics extends i {
1951
2049
  const isFooterActive = newTime > lastLyric.endtime + 200; // Snappier 200ms buffer
1952
2050
  if (isFooterActive && !footer.classList.contains('active')) {
1953
2051
  footer.classList.add('active');
2052
+ // Clear pre-active from the last lyric so it doesn't stay
2053
+ // unblurred when the footer takes over.
2054
+ const lastLine = this.lyricsContainer.querySelector('.lyrics-line:last-of-type');
2055
+ if (lastLine) {
2056
+ lastLine.classList.remove('pre-active');
2057
+ const preIdx = this.preActiveLineElements.indexOf(lastLine);
2058
+ if (preIdx !== -1)
2059
+ this.preActiveLineElements.splice(preIdx, 1);
2060
+ }
1954
2061
  if (this.autoScroll &&
1955
2062
  !this.isUserScrolling &&
1956
2063
  !this.isClickSeeking) {
@@ -1961,41 +2068,6 @@ class AmLyrics extends i {
1961
2068
  footer.classList.remove('active');
1962
2069
  }
1963
2070
  }
1964
- // Pre-scroll: scroll to upcoming line ~0.5s before it starts
1965
- if (this.autoScroll &&
1966
- !this.isUserScrolling &&
1967
- !this.isClickSeeking &&
1968
- this.lyrics) {
1969
- let preActiveLineIndex = null;
1970
- for (let i = 0; i < this.lyrics.length; i += 1) {
1971
- const line = this.lyrics[i];
1972
- const timeUntilStart = line.timestamp - newTime;
1973
- const nextLineEl = this._getLineElement(i);
1974
- const isBackToBack = this.activeLineIndices.length > 0;
1975
- const leadTime = isBackToBack
1976
- ? PRE_SCROLL_LEAD_SHORT_MS
1977
- : PRE_SCROLL_LEAD_MS;
1978
- if (timeUntilStart > leadTime) {
1979
- break;
1980
- }
1981
- if (timeUntilStart > 0 && timeUntilStart <= leadTime) {
1982
- if (nextLineEl) {
1983
- preActiveLineIndex = i;
1984
- if (!isBackToBack) {
1985
- nextLineEl.classList.add('pre-active');
1986
- if (!this.preActiveLineElements.includes(nextLineEl)) {
1987
- this.preActiveLineElements.push(nextLineEl);
1988
- }
1989
- }
1990
- this.clearPreActiveClasses(i);
1991
- const slowScrollDuration = Math.max(SCROLL_ANIMATION_DURATION_MS, timeUntilStart);
1992
- this.focusLine(nextLineEl, false, isBackToBack ? 500 : slowScrollDuration);
1993
- }
1994
- break;
1995
- }
1996
- }
1997
- this.clearPreActiveClasses(preActiveLineIndex);
1998
- }
1999
2071
  }
2000
2072
  }
2001
2073
  updated(changedProperties) {
@@ -2013,11 +2085,31 @@ class AmLyrics extends i {
2013
2085
  for (const lineIndex of activeLines) {
2014
2086
  const lineEl = this._getLineElement(lineIndex);
2015
2087
  if (lineEl)
2016
- lineEl.classList.add('active');
2088
+ lineEl.classList.add('active', 'bg-expanded');
2017
2089
  }
2018
2090
  // Trigger a faux time-change so that updateSyllablesForLine fires
2019
2091
  // to setup inline syllable CSS wipe animations for whatever the current time is
2020
2092
  this._onTimeChanged(0, this.currentTime);
2093
+ // Ensure position classes are applied on initial render if not playing yet
2094
+ if (this.positionedLineElements.length === 0) {
2095
+ const firstLine = this.lyricsContainer.querySelector('.lyrics-line');
2096
+ if (firstLine)
2097
+ this.updatePositionClasses(firstLine);
2098
+ }
2099
+ // Set up IntersectionObserver for viewport virtualization
2100
+ this.visibilityObserver?.disconnect();
2101
+ this.visibilityObserver = new IntersectionObserver(entries => {
2102
+ entries.forEach(entry => {
2103
+ const el = entry.target;
2104
+ el.classList.toggle('far-line', !entry.isIntersecting);
2105
+ });
2106
+ }, {
2107
+ root: this.lyricsContainer,
2108
+ rootMargin: '200px',
2109
+ threshold: 0,
2110
+ });
2111
+ const lines = this.lyricsContainer.querySelectorAll('.lyrics-line');
2112
+ lines.forEach(line => this.visibilityObserver.observe(line));
2021
2113
  }
2022
2114
  }
2023
2115
  // Handle duration reset (-1 stops playback and resets currentTime to 0)
@@ -2044,6 +2136,14 @@ class AmLyrics extends i {
2044
2136
  clearTimeout(this.userScrollTimeoutId);
2045
2137
  this.userScrollTimeoutId = undefined;
2046
2138
  }
2139
+ if (this.scrollUnlockTimeout) {
2140
+ clearTimeout(this.scrollUnlockTimeout);
2141
+ this.scrollUnlockTimeout = undefined;
2142
+ }
2143
+ if (this.scrollAnimationTimeout) {
2144
+ clearTimeout(this.scrollAnimationTimeout);
2145
+ this.scrollAnimationTimeout = undefined;
2146
+ }
2047
2147
  // Scroll to top
2048
2148
  if (this.lyricsContainer) {
2049
2149
  this.lyricsContainer.scrollTop = 0;
@@ -2065,30 +2165,56 @@ class AmLyrics extends i {
2065
2165
  /**
2066
2166
  * Handle scrolling when active line indices change.
2067
2167
  * Called imperatively from _onTimeChanged instead of from updated().
2168
+ *
2169
+ * Uses predictive scroll like YouLyPlus: computes a scrollLookAheadMs based
2170
+ * on the gap to the next line, finds the primary line at predictiveTime,
2171
+ * and scrolls with a duration matching the lookahead.
2068
2172
  */
2069
2173
  _handleActiveLineScroll(_oldActiveIndices, forceScroll = false) {
2070
- if (this.activeLineIndices.length === 0 || !this.lyricsContainer) {
2174
+ if (!this.lyricsContainer || !this.lyrics || this.lyrics.length === 0) {
2175
+ return;
2176
+ }
2177
+ // If the footer is already active, it set up its own scroll.
2178
+ // Don't override it with a scroll back to the last lyric.
2179
+ const footer = this.lyricsContainer.querySelector('.lyrics-footer');
2180
+ if (footer?.classList.contains('active')) {
2071
2181
  return;
2072
2182
  }
2073
- const targetLineIndex = this.getPrimaryActiveLineIndex(this.activeLineIndices);
2074
- if (targetLineIndex === null)
2183
+ // 1. Compute scroll lookahead based on gap to next line (YouLyPlus style)
2184
+ let scrollLookAheadMs = 350;
2185
+ const currentAudioIndex = this.getLineIndexAtTime(this.currentTime, this.lastActiveIndex);
2186
+ if (currentAudioIndex !== -1 &&
2187
+ currentAudioIndex + 1 < this.lyrics.length) {
2188
+ const currentLine = this.lyrics[currentAudioIndex];
2189
+ const nextLine = this.lyrics[currentAudioIndex + 1];
2190
+ const gap = nextLine.timestamp - currentLine.endtime;
2191
+ scrollLookAheadMs = Math.min(500, Math.max(350, gap));
2192
+ }
2193
+ // 2. Find scroll target at predictive time
2194
+ const predictiveTime = this.currentTime + scrollLookAheadMs;
2195
+ const predictiveActiveIndices = this.findActiveLineIndices(predictiveTime);
2196
+ let targetLineIndex;
2197
+ if (predictiveActiveIndices.length > 0) {
2198
+ targetLineIndex = this.getPrimaryScrollLineIndex(predictiveActiveIndices, predictiveTime);
2199
+ }
2200
+ else {
2201
+ // Fallback: closest line before predictiveTime
2202
+ targetLineIndex = this.getLineIndexAtTime(predictiveTime, 0);
2203
+ }
2204
+ if (targetLineIndex === null || targetLineIndex === -1)
2075
2205
  return;
2076
2206
  const targetLine = this._getLineElement(targetLineIndex);
2077
2207
  if (!targetLine)
2078
2208
  return;
2079
- // Only scroll snappily when lines are essentially back-to-back.
2080
- // If there is any noticeable gap between them, scroll slower.
2081
- let scrollDuration;
2082
- const prevPrimaryIndex = AmLyrics.getLineIndexFromElement(this.currentPrimaryActiveLine);
2083
- if (prevPrimaryIndex !== null &&
2084
- targetLineIndex > prevPrimaryIndex &&
2085
- this.lyrics) {
2086
- const gap = this.lyrics[targetLineIndex].timestamp -
2087
- this.lyrics[prevPrimaryIndex].endtime;
2088
- if (gap > 200) {
2089
- scrollDuration = Math.min(Math.max(gap * 0.85, SCROLL_ANIMATION_DURATION_MS), 4000);
2209
+ // Unblur the upcoming target line early (pre-active) so background
2210
+ // vocals start their max-height/opacity transition in sync with scroll.
2211
+ if (!targetLine.classList.contains('active')) {
2212
+ targetLine.classList.add('pre-active');
2213
+ if (!this.preActiveLineElements.includes(targetLine)) {
2214
+ this.preActiveLineElements.push(targetLine);
2090
2215
  }
2091
2216
  }
2217
+ const scrollDuration = scrollLookAheadMs;
2092
2218
  this.focusLine(targetLine, forceScroll, scrollDuration);
2093
2219
  }
2094
2220
  _getTextWidth(text, font) {
@@ -2109,6 +2235,7 @@ class AmLyrics extends i {
2109
2235
  return;
2110
2236
  this.lineElementCache.clear();
2111
2237
  this.gapElementCache.clear();
2238
+ this.cachedLineArray = [];
2112
2239
  if (!this.lyrics)
2113
2240
  return;
2114
2241
  for (let i = 0; i < this.lyrics.length; i += 1) {
@@ -2116,9 +2243,16 @@ class AmLyrics extends i {
2116
2243
  if (lineEl)
2117
2244
  this.lineElementCache.set(i, lineEl);
2118
2245
  const gapEl = this.lyricsContainer.querySelector(`#gap-${i}`);
2119
- if (gapEl)
2246
+ if (gapEl) {
2247
+ // Cache numeric timing values to avoid parseFloat on every frame
2248
+ gapEl._cachedStartTime = parseFloat(gapEl.getAttribute('data-start-time') || '0');
2249
+ gapEl._cachedEndTime = parseFloat(gapEl.getAttribute('data-end-time') || '0');
2120
2250
  this.gapElementCache.set(i, gapEl);
2251
+ }
2121
2252
  }
2253
+ // Rebuild cached line array for scroll/position queries
2254
+ const lineElements = this.lyricsContainer.querySelectorAll('.lyrics-line');
2255
+ this.cachedLineArray = Array.from(lineElements);
2122
2256
  }
2123
2257
  _getLineElement(index) {
2124
2258
  const cached = this.lineElementCache.get(index);
@@ -2148,9 +2282,13 @@ class AmLyrics extends i {
2148
2282
  this.cachedLineData = null;
2149
2283
  this.lineElementCache.clear();
2150
2284
  this.gapElementCache.clear();
2285
+ this.cachedLineArray = [];
2286
+ this.cachedScrollPaddingTop = null;
2151
2287
  this.preActiveLineElements = [];
2152
2288
  this.positionedLineElements = [];
2153
2289
  this.activeGapLineElements = [];
2290
+ this.visibilityObserver?.disconnect();
2291
+ this.visibilityObserver = undefined;
2154
2292
  }
2155
2293
  _updateCachedIsUnsynced() {
2156
2294
  this.cachedIsUnsynced =
@@ -2183,6 +2321,7 @@ class AmLyrics extends i {
2183
2321
  }
2184
2322
  const groupGrowable = new Array(wordGroups.length).fill(false);
2185
2323
  const groupGlowing = new Array(wordGroups.length).fill(false);
2324
+ const groupCharRise = new Array(wordGroups.length).fill(false);
2186
2325
  const vwFullText = new Array(wordGroups.length).fill('');
2187
2326
  const vwFullDuration = new Array(wordGroups.length).fill(0);
2188
2327
  const vwCharOffset = new Array(wordGroups.length).fill(0);
@@ -2227,10 +2366,12 @@ class AmLyrics extends i {
2227
2366
  }
2228
2367
  const isLineSynced = line.isWordSynced === false || line.text.some(s => s.lineSynced);
2229
2368
  const isGlowingVW = isGrowableVW && !isLineSynced;
2369
+ const isCharRiseVW = !isGrowableVW && !isLineSynced && !isCJK && !isRTL && wordLen >= 8;
2230
2370
  let charOff = 0;
2231
2371
  for (let gi = vwStart; gi <= vwEnd; gi += 1) {
2232
2372
  groupGrowable[gi] = isGrowableVW;
2233
2373
  groupGlowing[gi] = isGlowingVW;
2374
+ groupCharRise[gi] = isCharRiseVW;
2234
2375
  vwFullText[gi] = combinedText;
2235
2376
  vwFullDuration[gi] = combinedDuration;
2236
2377
  vwCharOffset[gi] = charOff;
@@ -2245,6 +2386,7 @@ class AmLyrics extends i {
2245
2386
  wordGroups,
2246
2387
  groupGrowable,
2247
2388
  groupGlowing,
2389
+ groupCharRise,
2248
2390
  vwFullText,
2249
2391
  vwFullDuration,
2250
2392
  vwCharOffset,
@@ -2265,10 +2407,10 @@ class AmLyrics extends i {
2265
2407
  const computedStyle = getComputedStyle(referenceSyllable);
2266
2408
  const { font } = computedStyle; // Full font string
2267
2409
  const fontSize = parseFloat(computedStyle.fontSize);
2268
- const growableWords = this.shadowRoot.querySelectorAll('.lyrics-word.growable');
2269
- if (!growableWords)
2410
+ const charTimedWords = this.shadowRoot.querySelectorAll('.lyrics-word.growable, .lyrics-word.char-rise');
2411
+ if (!charTimedWords)
2270
2412
  return;
2271
- growableWords.forEach((wordSpan) => {
2413
+ charTimedWords.forEach((wordSpan) => {
2272
2414
  const syllableWraps = wordSpan.querySelectorAll('.lyrics-syllable-wrap');
2273
2415
  // Flatten syllables
2274
2416
  const syllables = [];
@@ -2351,21 +2493,88 @@ class AmLyrics extends i {
2351
2493
  let candidateIndex = Math.max(groupStart, groupEnd - 2);
2352
2494
  const currentPrimaryIndex = AmLyrics.getLineIndexFromElement(this.currentPrimaryActiveLine);
2353
2495
  if (currentPrimaryIndex !== null &&
2354
- activeIndices.includes(currentPrimaryIndex) &&
2355
- candidateIndex < currentPrimaryIndex) {
2356
- candidateIndex = currentPrimaryIndex;
2496
+ activeIndices.includes(currentPrimaryIndex)) {
2497
+ if (activeIndices.length <= 3) {
2498
+ candidateIndex = currentPrimaryIndex;
2499
+ }
2500
+ else if (candidateIndex < currentPrimaryIndex) {
2501
+ candidateIndex = currentPrimaryIndex;
2502
+ }
2357
2503
  }
2358
2504
  return candidateIndex;
2359
2505
  }
2360
- focusLine(lineElement, forceScroll = false, scrollDuration = undefined, skipScroll = false) {
2506
+ getPrimaryScrollLineIndex(_activeIndices, time) {
2507
+ if (!this.lyrics || this.lyrics.length === 0)
2508
+ return null;
2509
+ // YouLyPlus-style: primary is simply the line at predictive time.
2510
+ const primaryIndex = this.getLineIndexAtTime(time, this.lastActiveIndex);
2511
+ if (primaryIndex === -1)
2512
+ return null;
2513
+ // Guard: if new primary is ahead of current but they share the same
2514
+ // end time, keep current to prevent bounce during overlaps.
2515
+ const currentPrimaryIndex = AmLyrics.getLineIndexFromElement(this.currentPrimaryActiveLine);
2516
+ if (currentPrimaryIndex !== null &&
2517
+ primaryIndex > currentPrimaryIndex &&
2518
+ this.lyrics[currentPrimaryIndex] &&
2519
+ this.lyrics[primaryIndex] &&
2520
+ this.lyrics[currentPrimaryIndex].endtime ===
2521
+ this.lyrics[primaryIndex].endtime) {
2522
+ const activeCount = this.findActiveLineIndices(time).length;
2523
+ if (activeCount <= 3) {
2524
+ return currentPrimaryIndex;
2525
+ }
2526
+ }
2527
+ return primaryIndex;
2528
+ }
2529
+ getOverlapClusterForActiveIndices(activeIndices, time) {
2530
+ if (!this.lyrics || activeIndices.length === 0)
2531
+ return null;
2532
+ let start = activeIndices[0];
2533
+ while (start > 0 &&
2534
+ this.lyrics[start - 1].endtime >= this.lyrics[start].timestamp) {
2535
+ start -= 1;
2536
+ }
2537
+ let end = start;
2538
+ let clusterEndTime = this.lyrics[start].endtime;
2539
+ while (end + 1 < this.lyrics.length &&
2540
+ this.lyrics[end + 1].timestamp <= clusterEndTime) {
2541
+ end += 1;
2542
+ clusterEndTime = Math.max(clusterEndTime, this.lyrics[end].endtime);
2543
+ }
2544
+ let startedEnd = start;
2545
+ let startedEndTime = this.lyrics[start].endtime;
2546
+ for (let i = start; i <= end; i += 1) {
2547
+ if (this.lyrics[i].timestamp <= time) {
2548
+ startedEnd = i;
2549
+ startedEndTime = Math.max(startedEndTime, this.lyrics[i].endtime);
2550
+ }
2551
+ else {
2552
+ break;
2553
+ }
2554
+ }
2555
+ return { start, end, startedEnd, startedEndTime };
2556
+ }
2557
+ focusLine(lineElement, forceScroll = false, scrollDuration = undefined, skipScroll = false, preservePrimary = false) {
2361
2558
  const primaryChanged = lineElement !== this.currentPrimaryActiveLine;
2362
- if (primaryChanged) {
2559
+ if (primaryChanged && !preservePrimary) {
2560
+ // .active is now managed solely by findActiveLineIndices (which uses
2561
+ // effectiveEndTimes). Lines stay active until their extended end,
2562
+ // so we no longer need to remove .active here.
2363
2563
  this.lastPrimaryActiveLine = this.currentPrimaryActiveLine;
2364
2564
  this.currentPrimaryActiveLine = lineElement;
2565
+ const lineIndex = AmLyrics.getLineIndexFromElement(lineElement);
2566
+ if (lineIndex !== null) {
2567
+ this.lastActiveIndex = lineIndex;
2568
+ }
2569
+ }
2570
+ // Only update blur/opacity position classes when the primary line
2571
+ // actually changes (or on force scroll). Running this every tick
2572
+ // causes visual churn and upward glitches.
2573
+ if (primaryChanged || forceScroll) {
2574
+ this.updatePositionClasses(lineElement);
2365
2575
  }
2366
- this.updatePositionClasses(lineElement);
2367
2576
  if (!skipScroll &&
2368
- (forceScroll || primaryChanged) &&
2577
+ (forceScroll || primaryChanged || preservePrimary) &&
2369
2578
  this.autoScroll &&
2370
2579
  !this.isUserScrolling &&
2371
2580
  !this.isClickSeeking) {
@@ -2388,6 +2597,7 @@ class AmLyrics extends i {
2388
2597
  }
2389
2598
  // Mark that user is currently scrolling
2390
2599
  this.setUserScrolling(true);
2600
+ this.clearPastLineHighlights();
2391
2601
  // Clear any existing timeout
2392
2602
  if (this.userScrollTimeoutId) {
2393
2603
  clearTimeout(this.userScrollTimeoutId);
@@ -2398,29 +2608,75 @@ class AmLyrics extends i {
2398
2608
  this.userScrollTimeoutId = undefined;
2399
2609
  // Optionally scroll back to current active line when re-enabling auto-scroll
2400
2610
  if (this.activeLineIndices.length > 0) {
2401
- this.scrollToActiveLine();
2611
+ this._handleActiveLineScroll([], false);
2402
2612
  }
2403
2613
  }, 2000);
2404
2614
  }
2615
+ clearPastLineHighlights() {
2616
+ if (!this.lyricsContainer)
2617
+ return;
2618
+ const lineElements = this.cachedLineArray.length
2619
+ ? this.cachedLineArray
2620
+ : Array.from(this.lyricsContainer.querySelectorAll('.lyrics-line:not(.lyrics-gap)'));
2621
+ const containerRect = this.lyricsContainer.getBoundingClientRect();
2622
+ const anchorY = containerRect.top + this.getScrollPaddingTop();
2623
+ for (let i = 0; i < lineElements.length; i += 1) {
2624
+ const lineElement = lineElements[i];
2625
+ const isActive = lineElement.classList.contains('active');
2626
+ const lineRect = lineElement.getBoundingClientRect();
2627
+ const hasScrolledPast = lineRect.bottom < anchorY - 2;
2628
+ if (!isActive && hasScrolledPast) {
2629
+ AmLyrics.unfinishSyllables(lineElement);
2630
+ }
2631
+ }
2632
+ }
2633
+ /**
2634
+ * Find the first (lowest-index) line whose raw time range contains `timeMs`.
2635
+ * Uses a stable forward scan so overlapping ranges always return the same
2636
+ * line, preventing primary-target jitter that causes scroll glitches.
2637
+ */
2638
+ getLineIndexAtTime(timeMs, startHintIndex = 0) {
2639
+ if (!this.lyrics || this.lyrics.length === 0)
2640
+ return -1;
2641
+ const len = this.lyrics.length;
2642
+ // 1. Check hint and immediate neighbours first (fast path)
2643
+ const hint = Math.max(0, Math.min(startHintIndex, len - 1));
2644
+ for (let i = hint; i < len; i += 1) {
2645
+ const line = this.lyrics[i];
2646
+ if (line.timestamp > timeMs)
2647
+ break;
2648
+ if (timeMs >= line.timestamp && timeMs < line.endtime) {
2649
+ return i;
2650
+ }
2651
+ }
2652
+ for (let i = hint - 1; i >= 0; i -= 1) {
2653
+ const line = this.lyrics[i];
2654
+ if (timeMs >= line.timestamp && timeMs < line.endtime) {
2655
+ return i;
2656
+ }
2657
+ if (line.endtime < timeMs)
2658
+ break;
2659
+ }
2660
+ // 2. Full forward scan — guaranteed deterministic for overlaps
2661
+ for (let i = 0; i < len; i += 1) {
2662
+ const line = this.lyrics[i];
2663
+ if (line.timestamp > timeMs)
2664
+ break;
2665
+ if (timeMs >= line.timestamp && timeMs < line.endtime) {
2666
+ return i;
2667
+ }
2668
+ }
2669
+ return -1;
2670
+ }
2405
2671
  findActiveLineIndices(time) {
2406
2672
  if (!this.lyrics || this.lyrics.length === 0)
2407
2673
  return [];
2408
2674
  const activeLines = [];
2409
2675
  for (let i = 0; i < this.lyrics.length; i += 1) {
2410
2676
  const line = this.lyrics[i];
2411
- let effectiveEndTime = line.endtime;
2412
- if (i < this.lyrics.length - 1) {
2413
- const nextLineStart = this.lyrics[i + 1].timestamp;
2414
- const gapDuration = nextLineStart - line.endtime;
2415
- if (gapDuration < INSTRUMENTAL_THRESHOLD_MS) {
2416
- if (effectiveEndTime < nextLineStart) {
2417
- effectiveEndTime = Math.max(effectiveEndTime, nextLineStart - 500);
2418
- }
2419
- }
2420
- }
2421
2677
  if (line.timestamp > time)
2422
2678
  break;
2423
- if (time >= line.timestamp && time <= effectiveEndTime) {
2679
+ if (time >= line.timestamp && time < line.endtime) {
2424
2680
  activeLines.push(i);
2425
2681
  }
2426
2682
  }
@@ -2630,10 +2886,6 @@ class AmLyrics extends i {
2630
2886
  this.scrollAnimationState.pendingUpdate = null;
2631
2887
  }
2632
2888
  // Clear scroll animation timeouts
2633
- if (this.scrollUnlockTimeout) {
2634
- clearTimeout(this.scrollUnlockTimeout);
2635
- this.scrollUnlockTimeout = undefined;
2636
- }
2637
2889
  if (this.scrollAnimationTimeout) {
2638
2890
  clearTimeout(this.scrollAnimationTimeout);
2639
2891
  this.scrollAnimationTimeout = undefined;
@@ -2728,6 +2980,7 @@ class AmLyrics extends i {
2728
2980
  const paddingTop = this.getScrollPaddingTop();
2729
2981
  const targetTranslateY = paddingTop - gapTarget.offsetTop;
2730
2982
  this.isProgrammaticScroll = true;
2983
+ this.clearPastLineHighlights();
2731
2984
  this.animateScrollYouLy(targetTranslateY, false);
2732
2985
  setTimeout(() => {
2733
2986
  this.isProgrammaticScroll = false;
@@ -2739,14 +2992,22 @@ class AmLyrics extends i {
2739
2992
  * Get the scroll padding top value from CSS variable
2740
2993
  */
2741
2994
  getScrollPaddingTop() {
2995
+ if (this.cachedScrollPaddingTop !== null)
2996
+ return this.cachedScrollPaddingTop;
2742
2997
  if (!this.lyricsContainer)
2743
2998
  return 0;
2744
2999
  const style = getComputedStyle(this);
2745
3000
  const paddingTopValue = style.getPropertyValue('--lyrics-scroll-padding-top') || '25%';
3001
+ let result;
2746
3002
  if (paddingTopValue.includes('%')) {
2747
- return (this.lyricsContainer.clientHeight * (parseFloat(paddingTopValue) / 100));
3003
+ result =
3004
+ this.lyricsContainer.clientHeight * (parseFloat(paddingTopValue) / 100);
2748
3005
  }
2749
- return parseFloat(paddingTopValue) || 0;
3006
+ else {
3007
+ result = parseFloat(paddingTopValue) || 0;
3008
+ }
3009
+ this.cachedScrollPaddingTop = result;
3010
+ return result;
2750
3011
  }
2751
3012
  /**
2752
3013
  * Animate scroll with staggered delay for smooth YouLyPlus-style scrolling
@@ -2755,6 +3016,7 @@ class AmLyrics extends i {
2755
3016
  if (!this.lyricsContainer)
2756
3017
  return;
2757
3018
  const parent = this.lyricsContainer;
3019
+ const targetTop = Math.max(0, -newTranslateY);
2758
3020
  if (!this.scrollAnimationState) {
2759
3021
  this.scrollAnimationState = {
2760
3022
  isAnimating: false,
@@ -2764,19 +3026,25 @@ class AmLyrics extends i {
2764
3026
  }
2765
3027
  const animState = this.scrollAnimationState;
2766
3028
  if (animState.isAnimating && !forceScroll) {
3029
+ const pendingTop = animState.pendingUpdate === null
3030
+ ? null
3031
+ : Math.max(0, -animState.pendingUpdate);
3032
+ if (Math.abs(parent.scrollTop - targetTop) < 2 ||
3033
+ (pendingTop !== null && Math.abs(pendingTop - targetTop) < 2)) {
3034
+ return;
3035
+ }
2767
3036
  animState.pendingUpdate = newTranslateY;
2768
3037
  return;
2769
3038
  }
2770
- if (this.scrollUnlockTimeout) {
2771
- clearTimeout(this.scrollUnlockTimeout);
2772
- this.scrollUnlockTimeout = undefined;
2773
- }
2774
3039
  if (this.scrollAnimationTimeout) {
2775
3040
  clearTimeout(this.scrollAnimationTimeout);
2776
3041
  this.scrollAnimationTimeout = undefined;
2777
3042
  }
3043
+ if (this.scrollUnlockTimeout) {
3044
+ clearTimeout(this.scrollUnlockTimeout);
3045
+ this.scrollUnlockTimeout = undefined;
3046
+ }
2778
3047
  const { animatingLines } = this;
2779
- const targetTop = Math.max(0, -newTranslateY);
2780
3048
  const appliedTranslateY = -targetTop;
2781
3049
  const prevOffset = -parent.scrollTop;
2782
3050
  const delta = prevOffset - appliedTranslateY;
@@ -2791,7 +3059,6 @@ class AmLyrics extends i {
2791
3059
  // Clean up any lingering scroll animations before smooth scroll
2792
3060
  for (const line of animatingLines) {
2793
3061
  line.classList.remove('scroll-animate');
2794
- line.style.removeProperty('will-change');
2795
3062
  line.style.removeProperty('--scroll-delta');
2796
3063
  line.style.removeProperty('--lyrics-line-delay');
2797
3064
  line.style.removeProperty('--scroll-duration');
@@ -2802,14 +3069,21 @@ class AmLyrics extends i {
2802
3069
  animState.pendingUpdate = null;
2803
3070
  return;
2804
3071
  }
2805
- // --- Step 1: Remove scroll-animate from ALL previously animating lines ---
3072
+ // --- Step 1: Remove scroll-animate and custom properties from ALL
3073
+ // previously animating lines so stale deltas don't interfere. ---
2806
3074
  for (const line of animatingLines) {
2807
3075
  line.classList.remove('scroll-animate');
3076
+ line.style.removeProperty('--scroll-delta');
3077
+ line.style.removeProperty('--lyrics-line-delay');
3078
+ line.style.removeProperty('--scroll-duration');
2808
3079
  }
2809
3080
  animatingLines.length = 0;
2810
- // Get lines for staggered animation
2811
- const lineElements = this.lyricsContainer.querySelectorAll('.lyrics-line');
2812
- const lineArray = Array.from(lineElements);
3081
+ // Get lines for staggered animation — use cached array
3082
+ if (this.cachedLineArray.length === 0) {
3083
+ const lineElements = this.lyricsContainer.querySelectorAll('.lyrics-line');
3084
+ this.cachedLineArray = Array.from(lineElements);
3085
+ }
3086
+ const lineArray = this.cachedLineArray;
2813
3087
  const referenceLine = this.currentPrimaryActiveLine ||
2814
3088
  this.lastPrimaryActiveLine ||
2815
3089
  lineArray[0];
@@ -2818,43 +3092,64 @@ class AmLyrics extends i {
2818
3092
  const referenceIndex = lineArray.indexOf(referenceLine);
2819
3093
  if (referenceIndex === -1)
2820
3094
  return;
2821
- const delayIncrement = SCROLL_DELAY_INCREMENT_MS;
2822
- const lookBehind = 10;
2823
- const lookAhead = 15;
3095
+ const duration = Math.min(450, scrollDuration ?? SCROLL_ANIMATION_DURATION_MS);
3096
+ const delayIncrement = duration * 0.1;
3097
+ const lookAhead = 20;
2824
3098
  const len = lineArray.length;
2825
- const start = Math.max(0, referenceIndex - lookBehind);
3099
+ const start = Math.max(0, referenceIndex - lookAhead);
2826
3100
  const end = Math.min(len, referenceIndex + lookAhead);
2827
3101
  let maxAnimationDuration = 0;
2828
- let delayCounter = 0;
2829
- // --- Step 2: Set CSS custom properties on target lines ---
2830
3102
  const newAnimatingLines = [];
2831
- for (let i = start; i < end; i += 1) {
2832
- const line = lineArray[i];
2833
- if (i >= referenceIndex)
2834
- delayCounter += 1;
2835
- const delay = i >= referenceIndex ? (delayCounter - 1) * delayIncrement : 0;
2836
- const duration = scrollDuration ?? SCROLL_ANIMATION_DURATION_MS;
2837
- line.style.setProperty('--scroll-delta', `${delta}px`);
2838
- line.style.setProperty('--lyrics-line-delay', `${delay}ms`);
2839
- line.style.setProperty('--scroll-duration', `${duration}ms`);
2840
- newAnimatingLines.push(line);
2841
- const lineDuration = duration + delay;
2842
- if (lineDuration > maxAnimationDuration) {
2843
- maxAnimationDuration = lineDuration;
3103
+ const scrollingDown = delta >= 0;
3104
+ if (scrollingDown) {
3105
+ let delayCounter = 0;
3106
+ for (let i = start; i < end; i += 1) {
3107
+ const line = lineArray[i];
3108
+ const delay = i >= referenceIndex ? delayCounter * delayIncrement : 0;
3109
+ if (i >= referenceIndex && !line.classList.contains('lyrics-gap')) {
3110
+ delayCounter += 1;
3111
+ }
3112
+ line.style.setProperty('--scroll-delta', `${delta}px`);
3113
+ line.style.setProperty('--lyrics-line-delay', `${delay}ms`);
3114
+ line.style.setProperty('--scroll-duration', `${duration + 100}ms`);
3115
+ newAnimatingLines.push(line);
3116
+ const lineDuration = duration + delay;
3117
+ if (lineDuration > maxAnimationDuration) {
3118
+ maxAnimationDuration = lineDuration;
3119
+ }
3120
+ }
3121
+ }
3122
+ else {
3123
+ let delayCounter = 0;
3124
+ for (let i = end - 1; i >= start; i -= 1) {
3125
+ const line = lineArray[i];
3126
+ const delay = i <= referenceIndex ? delayCounter * delayIncrement : 0;
3127
+ if (i <= referenceIndex && !line.classList.contains('lyrics-gap')) {
3128
+ delayCounter += 1;
3129
+ }
3130
+ line.style.setProperty('--scroll-delta', `${delta}px`);
3131
+ line.style.setProperty('--lyrics-line-delay', `${delay}ms`);
3132
+ line.style.setProperty('--scroll-duration', `${duration + 100}ms`);
3133
+ newAnimatingLines.push(line);
3134
+ const lineDuration = duration + delay;
3135
+ if (lineDuration > maxAnimationDuration) {
3136
+ maxAnimationDuration = lineDuration;
3137
+ }
2844
3138
  }
2845
3139
  }
2846
3140
  // --- Step 3: Force reflow so the browser sees the class removal ---
2847
- // This guarantees the animation restarts reliably, unlike the
2848
- // CSS-variable-toggle approach which doesn't restart in all browsers.
2849
- parent.getBoundingClientRect(); // force synchronous reflow
3141
+ // Use offsetHeight which is cheaper than getBoundingClientRect
3142
+ // eslint-disable-next-line no-void
3143
+ void parent.offsetHeight;
2850
3144
  // --- Step 4: Re-add scroll-animate class to start fresh animations ---
2851
3145
  for (const line of newAnimatingLines) {
2852
3146
  line.classList.add('scroll-animate');
2853
- line.style.willChange = 'transform';
2854
3147
  animatingLines.push(line);
2855
3148
  }
2856
3149
  animState.isAnimating = true;
2857
- const BASE_DURATION = scrollDuration ?? SCROLL_ANIMATION_DURATION_MS;
3150
+ // YouLyPlus-style early unlock: allow new scrolls to start after a
3151
+ // short base duration, even if CSS animations are still running.
3152
+ const BASE_DURATION = 400;
2858
3153
  this.scrollUnlockTimeout = setTimeout(() => {
2859
3154
  animState.isAnimating = false;
2860
3155
  if (animState.pendingUpdate !== null) {
@@ -2867,7 +3162,6 @@ class AmLyrics extends i {
2867
3162
  for (let i = 0; i < animatingLines.length; i += 1) {
2868
3163
  const line = animatingLines[i];
2869
3164
  line.classList.remove('scroll-animate');
2870
- line.style.removeProperty('will-change');
2871
3165
  line.style.removeProperty('--scroll-delta');
2872
3166
  line.style.removeProperty('--lyrics-line-delay');
2873
3167
  line.style.removeProperty('--scroll-duration');
@@ -2904,8 +3198,13 @@ class AmLyrics extends i {
2904
3198
  // Add new position classes
2905
3199
  lineToScroll.classList.add('lyrics-activest');
2906
3200
  this.positionedLineElements.push(lineToScroll);
2907
- const lineElements = Array.from(this.lyricsContainer.querySelectorAll('.lyrics-line'));
3201
+ if (this.cachedLineArray.length === 0) {
3202
+ this.cachedLineArray = Array.from(this.lyricsContainer.querySelectorAll('.lyrics-line'));
3203
+ }
3204
+ const lineElements = this.cachedLineArray;
2908
3205
  const scrollLineIndex = lineElements.indexOf(lineToScroll);
3206
+ if (scrollLineIndex === -1)
3207
+ return;
2909
3208
  for (let i = Math.max(0, scrollLineIndex - 4); i <= Math.min(lineElements.length - 1, scrollLineIndex + 4); i += 1) {
2910
3209
  const position = i - scrollLineIndex;
2911
3210
  if (position !== 0) {
@@ -2938,11 +3237,12 @@ class AmLyrics extends i {
2938
3237
  paddingTop) < 1) {
2939
3238
  return;
2940
3239
  }
2941
- // Skip scroll if near the bottom of content (prevents footer jitter)
3240
+ // Skip scroll if near the bottom of content and we aren't trying to scroll back up
2942
3241
  if (!forceScroll && !activeLine.classList.contains('lyrics-footer')) {
2943
3242
  const parent = this.lyricsContainer;
2944
3243
  const atBottom = parent.scrollTop + parent.clientHeight >= parent.scrollHeight - 50;
2945
- if (atBottom) {
3244
+ const targetTop = Math.max(0, -(paddingTop - activeLine.offsetTop));
3245
+ if (atBottom && targetTop > parent.scrollTop - 50) {
2946
3246
  return;
2947
3247
  }
2948
3248
  }
@@ -2953,6 +3253,7 @@ class AmLyrics extends i {
2953
3253
  clearTimeout(this.userScrollTimeoutId);
2954
3254
  this.userScrollTimeoutId = undefined;
2955
3255
  }
3256
+ this.clearPastLineHighlights();
2956
3257
  const duration = scrollDuration ?? SCROLL_ANIMATION_DURATION_MS;
2957
3258
  setTimeout(() => {
2958
3259
  this.isProgrammaticScroll = false;
@@ -2974,6 +3275,7 @@ class AmLyrics extends i {
2974
3275
  ? Array.from(wordElement.querySelectorAll('span.char'))
2975
3276
  : [];
2976
3277
  const isGrowable = wordElement?.classList.contains('growable');
3278
+ const isCharRise = wordElement?.classList.contains('char-rise');
2977
3279
  const isFirstSyllable = syllable.getAttribute('data-syllable-index') === '0';
2978
3280
  const isFirstInContainer = isFirstSyllable; // Simplified
2979
3281
  const isGap = syllable.closest('.lyrics-gap') !== null;
@@ -3020,6 +3322,16 @@ class AmLyrics extends i {
3020
3322
  });
3021
3323
  });
3022
3324
  }
3325
+ if (isCharRise && isFirstSyllable && allWordCharSpans.length > 0) {
3326
+ const finalDuration = Math.max(wordDurationMs, syllableDurationMs);
3327
+ const baseDelayPerChar = finalDuration * 0.09;
3328
+ const riseDurationMs = finalDuration * 1.5;
3329
+ allWordCharSpans.forEach(span => {
3330
+ const charIndex = parseFloat(span.dataset.syllableCharIndex || '0');
3331
+ const riseDelay = baseDelayPerChar * charIndex;
3332
+ charAnimationsMap.set(span, `rise-char ${riseDurationMs}ms ease-in-out ${riseDelay}ms forwards`);
3333
+ });
3334
+ }
3023
3335
  // Step 2: Wipe Pass
3024
3336
  if (charSpans.length > 0) {
3025
3337
  charSpans.forEach((span, charIndex) => {
@@ -3037,7 +3349,9 @@ class AmLyrics extends i {
3037
3349
  }
3038
3350
  const existingAnimation = charAnimationsMap.get(span) || span.style.animation || '';
3039
3351
  const animationParts = [];
3040
- if (existingAnimation && existingAnimation.includes('grow-dynamic')) {
3352
+ if (existingAnimation &&
3353
+ (existingAnimation.includes('grow-dynamic') ||
3354
+ existingAnimation.includes('rise-char'))) {
3041
3355
  animationParts.push(existingAnimation.split(',')[0].trim());
3042
3356
  }
3043
3357
  if (charIndex > 0) {
@@ -3103,36 +3417,119 @@ class AmLyrics extends i {
3103
3417
  // eslint-disable-next-line no-param-reassign
3104
3418
  syllable.style.backgroundColor = 'var(--lyplus-text-secondary)';
3105
3419
  // Reset character animations — disable transition so finished chars don't slowly fade
3106
- syllable.querySelectorAll('span.char').forEach(span => {
3107
- const el = span;
3420
+ const charSpans = syllable.querySelectorAll('span.char');
3421
+ for (let i = 0; i < charSpans.length; i += 1) {
3422
+ const el = charSpans[i];
3108
3423
  el.style.animation = '';
3109
- el.style.willChange = '';
3110
3424
  el.style.transition = 'none';
3111
3425
  el.style.backgroundColor = 'var(--lyplus-text-secondary)';
3112
- });
3426
+ }
3113
3427
  // Immediately remove all state classes
3114
3428
  syllable.classList.remove('highlight', 'finished', 'pre-highlight', 'cleanup');
3115
- // In next frame, clear inline styles so CSS transitions can resume for future use
3116
- requestAnimationFrame(() => {
3117
- syllable.style.removeProperty('background-color');
3118
- syllable.style.removeProperty('transition');
3119
- syllable.querySelectorAll('span.char').forEach(span => {
3120
- const el = span;
3121
- el.style.removeProperty('background-color');
3122
- el.style.removeProperty('transition');
3123
- el.style.removeProperty('will-change');
3124
- });
3125
- });
3126
3429
  }
3127
3430
  /**
3128
- * Reset all syllables in a line
3431
+ * Reset all syllables in a line — batches deferred cleanup into a single rAF
3129
3432
  */
3130
3433
  static resetSyllables(line) {
3131
3434
  if (!line)
3132
3435
  return;
3436
+ line.classList.remove('persist-highlight');
3133
3437
  // eslint-disable-next-line no-param-reassign
3134
3438
  line._cachedSyllableElements = null;
3135
- Array.from(line.getElementsByClassName('lyrics-syllable')).forEach(syllable => AmLyrics.resetSyllable(syllable));
3439
+ const syllables = line.getElementsByClassName('lyrics-syllable');
3440
+ for (let i = 0; i < syllables.length; i += 1) {
3441
+ AmLyrics.resetSyllable(syllables[i]);
3442
+ }
3443
+ // Batch deferred style cleanup into a single rAF for all syllables in the line
3444
+ requestAnimationFrame(() => {
3445
+ for (let i = 0; i < syllables.length; i += 1) {
3446
+ const syllable = syllables[i];
3447
+ syllable.style.removeProperty('background-color');
3448
+ syllable.style.removeProperty('transition');
3449
+ const chars = syllable.querySelectorAll('span.char');
3450
+ for (let j = 0; j < chars.length; j += 1) {
3451
+ const el = chars[j];
3452
+ el.style.removeProperty('background-color');
3453
+ el.style.removeProperty('transition');
3454
+ el.style.removeProperty('will-change');
3455
+ }
3456
+ }
3457
+ });
3458
+ }
3459
+ /**
3460
+ * Gentle reset for normal playback: remove highlight/finished classes
3461
+ * without forcing inline styles. Lets CSS transition fade syllables
3462
+ * back to secondary colour smoothly.
3463
+ */
3464
+ static unfinishSyllables(line) {
3465
+ if (!line)
3466
+ return;
3467
+ line.classList.remove('persist-highlight');
3468
+ const syllables = line.getElementsByClassName('lyrics-syllable');
3469
+ for (let i = 0; i < syllables.length; i += 1) {
3470
+ const s = syllables[i];
3471
+ s.classList.remove('highlight', 'finished', 'pre-highlight', 'cleanup');
3472
+ s.style.animation = '';
3473
+ s.style.removeProperty('--pre-wipe-duration');
3474
+ s.style.removeProperty('--pre-wipe-delay');
3475
+ s.style.removeProperty('background-color');
3476
+ s.style.removeProperty('transition');
3477
+ const chars = s.querySelectorAll('span.char');
3478
+ for (let j = 0; j < chars.length; j += 1) {
3479
+ const el = chars[j];
3480
+ el.style.animation = '';
3481
+ el.style.removeProperty('will-change');
3482
+ el.style.removeProperty('background-color');
3483
+ el.style.removeProperty('transition');
3484
+ el.style.removeProperty('filter');
3485
+ }
3486
+ }
3487
+ }
3488
+ static finishSyllablesUpToTime(line, currentTimeMs) {
3489
+ if (!line)
3490
+ return;
3491
+ let hasFinishedSyllable = false;
3492
+ let syllables = line._cachedSyllableElements;
3493
+ if (!syllables) {
3494
+ syllables = Array.from(line.querySelectorAll('.lyrics-syllable'));
3495
+ for (let i = 0; i < syllables.length; i += 1) {
3496
+ const syllable = syllables[i];
3497
+ syllable._cachedStartTime = parseFloat(syllable.getAttribute('data-start-time') || '0');
3498
+ syllable._cachedEndTime = parseFloat(syllable.getAttribute('data-end-time') || '0');
3499
+ }
3500
+ // eslint-disable-next-line no-param-reassign
3501
+ line._cachedSyllableElements = syllables;
3502
+ }
3503
+ for (let i = 0; i < syllables.length; i += 1) {
3504
+ const syllable = syllables[i];
3505
+ const startTime = syllable._cachedStartTime;
3506
+ if (Number.isFinite(startTime) && currentTimeMs >= startTime) {
3507
+ const { classList } = syllable;
3508
+ if (!classList.contains('finished')) {
3509
+ if (!classList.contains('highlight')) {
3510
+ AmLyrics.updateSyllableAnimation(syllable, Math.max(0, currentTimeMs - startTime));
3511
+ }
3512
+ classList.add('finished');
3513
+ }
3514
+ hasFinishedSyllable = true;
3515
+ classList.remove('highlight');
3516
+ classList.remove('pre-highlight');
3517
+ classList.add('cleanup');
3518
+ syllable.style.animation = '';
3519
+ syllable.style.removeProperty('--pre-wipe-duration');
3520
+ syllable.style.removeProperty('--pre-wipe-delay');
3521
+ const chars = syllable.querySelectorAll('span.char');
3522
+ for (let ci = 0; ci < chars.length; ci += 1) {
3523
+ chars[ci].style.animation = '';
3524
+ }
3525
+ }
3526
+ }
3527
+ if (hasFinishedSyllable) {
3528
+ line.classList.add('persist-highlight');
3529
+ }
3530
+ else {
3531
+ line.classList.remove('persist-highlight');
3532
+ }
3136
3533
  }
3137
3534
  /**
3138
3535
  * Update syllables based on current time
@@ -3143,13 +3540,18 @@ class AmLyrics extends i {
3143
3540
  let syllables = line._cachedSyllableElements;
3144
3541
  if (!syllables) {
3145
3542
  syllables = Array.from(line.querySelectorAll('.lyrics-syllable'));
3543
+ for (let i = 0; i < syllables.length; i += 1) {
3544
+ const syllable = syllables[i];
3545
+ syllable._cachedStartTime = parseFloat(syllable.getAttribute('data-start-time') || '0');
3546
+ syllable._cachedEndTime = parseFloat(syllable.getAttribute('data-end-time') || '0');
3547
+ }
3146
3548
  // eslint-disable-next-line no-param-reassign
3147
3549
  line._cachedSyllableElements = syllables;
3148
3550
  }
3149
3551
  for (let i = 0; i < syllables.length; i += 1) {
3150
3552
  const syllable = syllables[i];
3151
- const startTime = parseFloat(syllable.getAttribute('data-start-time') || '0');
3152
- const endTime = parseFloat(syllable.getAttribute('data-end-time') || '0');
3553
+ const startTime = syllable._cachedStartTime;
3554
+ const endTime = syllable._cachedEndTime;
3153
3555
  if (Number.isFinite(startTime) && Number.isFinite(endTime)) {
3154
3556
  const { classList } = syllable;
3155
3557
  const hasHighlight = classList.contains('highlight');
@@ -3187,6 +3589,7 @@ class AmLyrics extends i {
3187
3589
  AmLyrics.updateSyllableAnimation(syllable, currentTimeMs - startTime);
3188
3590
  }
3189
3591
  classList.add('finished');
3592
+ // Keep the completed wipe state until user scroll resets it.
3190
3593
  }
3191
3594
  }
3192
3595
  else if (hasHighlight || hasFinished) {
@@ -3469,7 +3872,8 @@ class AmLyrics extends i {
3469
3872
  // Create background vocals container (with romanization support)
3470
3873
  const backgroundVocalElement = hasBackground
3471
3874
  ? b `<p class="background-vocal-container">
3472
- ${line.backgroundText.map((syllable, syllableIndex) => {
3875
+ <span class="background-vocal-wrap">
3876
+ ${line.backgroundText.map((syllable, syllableIndex) => {
3473
3877
  const startTimeMs = syllable.timestamp;
3474
3878
  const endTimeMs = syllable.endtime;
3475
3879
  const durationMs = endTimeMs - startTimeMs;
@@ -3477,36 +3881,37 @@ class AmLyrics extends i {
3477
3881
  syllable.romanizedText &&
3478
3882
  syllable.romanizedText.trim() !== syllable.text.trim()
3479
3883
  ? b `<span
3480
- class="lyrics-syllable transliteration ${syllable.lineSynced
3884
+ class="lyrics-syllable transliteration no-chars ${syllable.lineSynced
3481
3885
  ? 'line-synced'
3482
3886
  : ''}"
3483
- data-start-time="${startTimeMs}"
3484
- data-end-time="${endTimeMs}"
3485
- data-duration="${durationMs}"
3486
- data-syllable-index="0"
3487
- data-wipe-ratio="1"
3488
- >${syllable.romanizedText}</span
3489
- >`
3887
+ data-start-time="${startTimeMs}"
3888
+ data-end-time="${endTimeMs}"
3889
+ data-duration="${durationMs}"
3890
+ data-syllable-index="0"
3891
+ data-wipe-ratio="1"
3892
+ >${syllable.romanizedText}</span
3893
+ >`
3490
3894
  : '';
3491
3895
  return b `<span class="lyrics-word"
3492
- ><span
3493
- class="lyrics-syllable-wrap${bgRomanizedText
3896
+ ><span
3897
+ class="lyrics-syllable-wrap${bgRomanizedText
3494
3898
  ? ' has-transliteration'
3495
3899
  : ''}"
3496
- ><span
3497
- class="lyrics-syllable no-chars${syllable.lineSynced
3900
+ ><span
3901
+ class="lyrics-syllable no-chars${syllable.lineSynced
3498
3902
  ? ' line-synced'
3499
3903
  : ''}"
3500
- data-start-time="${startTimeMs}"
3501
- data-end-time="${endTimeMs}"
3502
- data-duration="${durationMs}"
3503
- data-syllable-index="${syllableIndex}"
3504
- data-wipe-ratio="1"
3505
- >${syllable.text}</span
3506
- >${bgRomanizedText}</span
3507
- ></span
3508
- >`;
3904
+ data-start-time="${startTimeMs}"
3905
+ data-end-time="${endTimeMs}"
3906
+ data-duration="${durationMs}"
3907
+ data-syllable-index="${syllableIndex}"
3908
+ data-wipe-ratio="1"
3909
+ >${syllable.text}</span
3910
+ >${bgRomanizedText}</span
3911
+ ></span
3912
+ >`;
3509
3913
  })}
3914
+ </span>
3510
3915
  </p>`
3511
3916
  : '';
3512
3917
  // Background vocals share the same line.translation and line.romanizedText
@@ -3520,6 +3925,7 @@ class AmLyrics extends i {
3520
3925
  const wordGroups = lineData?.wordGroups ?? [];
3521
3926
  const groupGrowable = lineData?.groupGrowable ?? [];
3522
3927
  const groupGlowing = lineData?.groupGlowing ?? [];
3928
+ const groupCharRise = lineData?.groupCharRise ?? [];
3523
3929
  const vwFullText = lineData?.vwFullText ?? [];
3524
3930
  const vwFullDuration = lineData?.vwFullDuration ?? [];
3525
3931
  const vwCharOffset = lineData?.vwCharOffset ?? [];
@@ -3531,11 +3937,17 @@ class AmLyrics extends i {
3531
3937
  ${wordGroups.map((group, groupIdx) => {
3532
3938
  const isGrowable = groupGrowable[groupIdx];
3533
3939
  const isGlowing = groupGlowing[groupIdx];
3940
+ const isCharRise = groupCharRise[groupIdx];
3941
+ const isAnimatedByChar = isGrowable || isCharRise;
3534
3942
  const groupLineSynced = group.some(s => s.lineSynced);
3535
- const wordText = isGrowable ? vwFullText[groupIdx] : '';
3536
- const wordDuration = isGrowable ? vwFullDuration[groupIdx] : 0;
3943
+ const wordText = isAnimatedByChar ? vwFullText[groupIdx] : '';
3944
+ const wordDuration = isAnimatedByChar
3945
+ ? vwFullDuration[groupIdx]
3946
+ : 0;
3537
3947
  const wordNumChars = wordText.length;
3538
- const groupCharOffset = isGrowable ? vwCharOffset[groupIdx] : 0;
3948
+ const groupCharOffset = isAnimatedByChar
3949
+ ? vwCharOffset[groupIdx]
3950
+ : 0;
3539
3951
  let sylCharAccumulator = 0;
3540
3952
  const groupText = group.map(s => s.text).join('');
3541
3953
  const shouldAllowBreak = groupText.trim().length >= 16 ||
@@ -3547,9 +3959,11 @@ class AmLyrics extends i {
3547
3959
  // Base float is 0.8s, plus a portion of the audio duration, capped between 1.0s and 2.5s
3548
3960
  const riseDuration = Math.max(1.2, Math.min(2.5, 1.2 + (actualDurationMs / 1000) * 0.6));
3549
3961
  return b `<span
3550
- class="lyrics-word${isGrowable ? ' growable' : ''}${isGlowing
3551
- ? ' glowing'
3552
- : ''}${shouldAllowBreak ? ' allow-break' : ''}"
3962
+ class="lyrics-word${isGrowable ? ' growable' : ''}${isCharRise
3963
+ ? ' char-rise'
3964
+ : ''}${isGlowing ? ' glowing' : ''}${shouldAllowBreak
3965
+ ? ' allow-break'
3966
+ : ''}"
3553
3967
  style="--rise-duration: ${riseDuration}s"
3554
3968
  >${group.map((syllable, sylIdx) => {
3555
3969
  const startTimeMs = syllable.timestamp;
@@ -3560,7 +3974,7 @@ class AmLyrics extends i {
3560
3974
  syllable.romanizedText &&
3561
3975
  syllable.romanizedText.trim() !== syllable.text.trim()
3562
3976
  ? b `<span
3563
- class="lyrics-syllable transliteration ${groupLineSynced
3977
+ class="lyrics-syllable transliteration no-chars ${groupLineSynced
3564
3978
  ? 'line-synced'
3565
3979
  : ''}"
3566
3980
  data-start-time="${startTimeMs}"
@@ -3572,7 +3986,7 @@ class AmLyrics extends i {
3572
3986
  >`
3573
3987
  : '';
3574
3988
  let syllableContent = text;
3575
- if (isGrowable) {
3989
+ if (isAnimatedByChar) {
3576
3990
  let charIndexInsideSyllable = 0;
3577
3991
  const numCharsInSyllable = text.replace(/\s/g, '').length || 1;
3578
3992
  syllableContent = b `${text.split('').map(char => {
@@ -3652,7 +4066,7 @@ class AmLyrics extends i {
3652
4066
  ><span
3653
4067
  class="lyrics-syllable${groupLineSynced
3654
4068
  ? ' line-synced'
3655
- : ''}${isGrowable ? ' has-chars' : ' no-chars'}"
4069
+ : ''}${isAnimatedByChar ? ' has-chars' : ' no-chars'}"
3656
4070
  data-start-time="${startTimeMs}"
3657
4071
  data-end-time="${endTimeMs}"
3658
4072
  data-duration="${durationMs}"
@@ -4014,6 +4428,7 @@ AmLyrics.styles = i$3 `
4014
4428
  -webkit-overflow-scrolling: touch;
4015
4429
  box-sizing: border-box;
4016
4430
  scrollbar-width: none;
4431
+ overflow-anchor: none;
4017
4432
  }
4018
4433
 
4019
4434
  .lyrics-container::-webkit-scrollbar {
@@ -4034,9 +4449,16 @@ AmLyrics.styles = i$3 `
4034
4449
  }
4035
4450
 
4036
4451
  .lyrics-line.scroll-animate {
4037
- transition: none !important; /* Prevent conflict with scroll animation */
4452
+ /* Preserve the graceful fade duration; the keyframe handles the
4453
+ transform, so we only need to keep opacity/filter transitions
4454
+ alive without !important overriding the base rule. */
4455
+ transition:
4456
+ opacity 0.7s ease,
4457
+ filter 0.7s ease,
4458
+ transform 0.4s cubic-bezier(0.41, 0, 0.12, 0.99)
4459
+ var(--lyrics-line-delay, 0ms);
4038
4460
  animation-name: lyrics-scroll;
4039
- animation-duration: var(--scroll-duration, 280ms);
4461
+ animation-duration: var(--scroll-duration, 400ms);
4040
4462
  animation-timing-function: cubic-bezier(0.41, 0, 0.12, 0.99);
4041
4463
  animation-fill-mode: both;
4042
4464
  animation-delay: var(--lyrics-line-delay, 0ms);
@@ -4057,12 +4479,15 @@ AmLyrics.styles = i$3 `
4057
4479
  font-size: var(--lyplus-font-size-base);
4058
4480
  cursor: pointer;
4059
4481
  transform-origin: left;
4482
+ /* Graceful 0.7 s fade so the line stays mostly bright while the
4483
+ 0.4 s scroll animation runs, then settles into the inactive state. */
4060
4484
  transition:
4061
- opacity 0.3s ease,
4485
+ opacity 0.7s ease,
4062
4486
  transform 0.4s cubic-bezier(0.41, 0, 0.12, 0.99)
4063
4487
  var(--lyrics-line-delay, 0ms),
4064
- filter 0.3s ease;
4488
+ filter 0.7s ease;
4065
4489
  content-visibility: auto;
4490
+ contain: layout style;
4066
4491
  text-rendering: optimizeLegibility;
4067
4492
  }
4068
4493
 
@@ -4074,7 +4499,7 @@ AmLyrics.styles = i$3 `
4074
4499
  .lyrics-line-container {
4075
4500
  overflow-wrap: break-word;
4076
4501
  transform-origin: left;
4077
- transform: scale3d(0.93, 0.93, 0.95);
4502
+ transform: translateZ(0);
4078
4503
  transition:
4079
4504
  transform 0.7s ease,
4080
4505
  background-color 0.7s,
@@ -4083,7 +4508,7 @@ AmLyrics.styles = i$3 `
4083
4508
 
4084
4509
  .lyrics-line.active .lyrics-line-container,
4085
4510
  .lyrics-line.pre-active .lyrics-line-container {
4086
- transform: scale3d(1.001, 1.001, 1) translateZ(0);
4511
+ transform: translateZ(0);
4087
4512
  transition:
4088
4513
  transform 0.5s ease,
4089
4514
  background-color 0.18s,
@@ -4097,31 +4522,50 @@ AmLyrics.styles = i$3 `
4097
4522
 
4098
4523
  .background-vocal-container {
4099
4524
  max-height: 0;
4100
- padding-top: 0;
4101
- transform: translateY(-0.5em) scale(0.95);
4102
4525
  overflow: visible;
4103
4526
  opacity: 0;
4104
4527
  font-size: var(--lyplus-font-size-subtext);
4528
+ line-height: 1.15;
4529
+ color: color-mix(in srgb, var(--lyplus-text-secondary) 80%, transparent);
4530
+ /* Fast exit (0.25 s) so bg vocals collapse quickly and feel snappy.
4531
+ The scroll takes ~0.4 s; finishing the collapse first prevents
4532
+ the container from trailing behind the motion. */
4105
4533
  transition:
4106
- max-height 450ms cubic-bezier(0.33, 1, 0.68, 1),
4107
- opacity 400ms ease-out,
4108
- transform 450ms cubic-bezier(0.33, 1, 0.68, 1),
4109
- padding 450ms cubic-bezier(0.33, 1, 0.68, 1);
4534
+ max-height 250ms cubic-bezier(0.41, 0, 0.12, 0.99),
4535
+ opacity 250ms cubic-bezier(0.41, 0, 0.12, 0.99);
4110
4536
  margin: 0;
4537
+ pointer-events: none;
4111
4538
  }
4112
4539
 
4113
- .lyrics-line.active .background-vocal-container,
4114
- .lyrics-line.pre-active .background-vocal-container {
4540
+ .background-vocal-wrap {
4541
+ display: block;
4542
+ padding-top: 0;
4543
+ padding-bottom: 0;
4544
+ transition: padding-top 250ms cubic-bezier(0.41, 0, 0.12, 0.99);
4545
+ }
4546
+
4547
+ .lyrics-line.singer-right .background-vocal-container,
4548
+ .lyrics-line.rtl-text .background-vocal-container {
4549
+ margin-left: auto;
4550
+ margin-right: 0;
4551
+ }
4552
+
4553
+ /* Background vocals expand only when .bg-expanded is present.
4554
+ This is separate from .active so bg vocals can collapse immediately
4555
+ while .active stays to keep text white until the scroll passes. */
4556
+ .lyrics-line.bg-expanded .background-vocal-container {
4115
4557
  max-height: 4em;
4116
4558
  opacity: 1;
4117
- padding-top: 0.2em;
4118
- transform: translateY(0) scale(1);
4559
+ /* Slower entry (0.6 s) so bg vocals expand smoothly. */
4119
4560
  transition:
4120
- max-height 450ms cubic-bezier(0.22, 1, 0.36, 1),
4121
- opacity 400ms ease-out,
4122
- transform 450ms cubic-bezier(0.22, 1, 0.36, 1),
4123
- padding 450ms cubic-bezier(0.22, 1, 0.36, 1);
4124
- will-change: max-height, opacity, padding, transform;
4561
+ max-height 0.6s ease,
4562
+ opacity 0.6s ease;
4563
+ will-change: max-height, opacity;
4564
+ }
4565
+
4566
+ .lyrics-line.bg-expanded .background-vocal-wrap {
4567
+ padding-top: 0.26em;
4568
+ transition: padding-top 0.6s ease;
4125
4569
  }
4126
4570
 
4127
4571
  /* --- Line States & Modifiers --- */
@@ -4134,6 +4578,16 @@ AmLyrics.styles = i$3 `
4134
4578
  opacity: 1;
4135
4579
  }
4136
4580
 
4581
+ .lyrics-line.persist-highlight {
4582
+ filter: none !important;
4583
+ opacity: 1;
4584
+ }
4585
+
4586
+ .lyrics-line.persist-highlight .lyrics-syllable.finished,
4587
+ .lyrics-line.persist-highlight .lyrics-syllable.finished span.char {
4588
+ transition: none !important;
4589
+ }
4590
+
4137
4591
  .lyrics-line.singer-right {
4138
4592
  text-align: end;
4139
4593
  }
@@ -4198,22 +4652,32 @@ AmLyrics.styles = i$3 `
4198
4652
 
4199
4653
  /* --- Blur Effect for Inactive Lines --- */
4200
4654
  .lyrics-container.blur-inactive-enabled:not(.not-focused)
4201
- .lyrics-line:not(.active):not(.pre-active):not(.lyrics-gap) {
4655
+ .lyrics-line:not(.active):not(.pre-active):not(.lyrics-gap):not(
4656
+ .persist-highlight
4657
+ ) {
4202
4658
  filter: blur(var(--lyplus-blur-amount));
4203
4659
  }
4204
4660
 
4661
+ /* Viewport Virtualization: Strip expensive filters and animations from
4662
+ offscreen lines. IntersectionObserver toggles this class. */
4663
+ .lyrics-line.far-line {
4664
+ filter: none !important;
4665
+ will-change: auto !important;
4666
+ animation: none !important;
4667
+ }
4668
+
4205
4669
  .lyrics-container.blur-inactive-enabled:not(.not-focused)
4206
4670
  .lyrics-line.post-active-line:not(.lyrics-gap):not(.active):not(
4207
4671
  .pre-active
4208
- ),
4672
+ ):not(.persist-highlight),
4209
4673
  .lyrics-container.blur-inactive-enabled:not(.not-focused)
4210
4674
  .lyrics-line.next-active-line:not(.lyrics-gap):not(.active):not(
4211
4675
  .pre-active
4212
- ),
4676
+ ):not(.persist-highlight),
4213
4677
  .lyrics-container.blur-inactive-enabled:not(.not-focused)
4214
4678
  .lyrics-line.lyrics-activest:not(.active):not(.lyrics-gap):not(
4215
4679
  .pre-active
4216
- ) {
4680
+ ):not(.persist-highlight) {
4217
4681
  filter: blur(var(--lyplus-blur-amount-near));
4218
4682
  }
4219
4683
 
@@ -4243,6 +4707,17 @@ AmLyrics.styles = i$3 `
4243
4707
  display: inline;
4244
4708
  }
4245
4709
 
4710
+ .lyrics-word.char-rise {
4711
+ display: inline-block;
4712
+ vertical-align: baseline;
4713
+ white-space: nowrap;
4714
+ }
4715
+
4716
+ .lyrics-word.char-rise.allow-break {
4717
+ display: inline;
4718
+ white-space: normal;
4719
+ }
4720
+
4246
4721
  .lyrics-syllable-wrap {
4247
4722
  display: inline;
4248
4723
  }
@@ -4272,17 +4747,19 @@ AmLyrics.styles = i$3 `
4272
4747
  /* --- Syllable States --- */
4273
4748
  .lyrics-syllable.finished {
4274
4749
  background-color: var(--lyplus-text-primary);
4275
- transition: transform 1s ease !important;
4750
+ /* Unified transition: transform keeps its 1s glow decay, while
4751
+ background-color and color fade at 0.7s so everything dims
4752
+ together when the line becomes inactive. */
4753
+ transition:
4754
+ transform 1s ease,
4755
+ background-color 0.7s ease,
4756
+ color 0.7s ease;
4276
4757
  }
4277
4758
 
4278
4759
  .lyrics-syllable.finished.has-chars {
4279
4760
  background-color: transparent;
4280
4761
  }
4281
4762
 
4282
- .lyrics-line:not(.active) .lyrics-syllable.finished {
4283
- transition: color 0.18s;
4284
- }
4285
-
4286
4763
  .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable {
4287
4764
  transition:
4288
4765
  transform 1s ease,
@@ -4332,7 +4809,60 @@ AmLyrics.styles = i$3 `
4332
4809
  );
4333
4810
  background-position:
4334
4811
  calc(100% + 0.5em) 0%,
4335
- right;
4812
+ right 0%;
4813
+ }
4814
+
4815
+ /* Background vocals: muted gray wipe instead of white.
4816
+ Must match specificity of the main .active .highlight rule (0,3,1). */
4817
+ .lyrics-line.active
4818
+ .background-vocal-container
4819
+ .lyrics-syllable.highlight.no-chars,
4820
+ .lyrics-line.active
4821
+ .background-vocal-container
4822
+ .lyrics-syllable.pre-highlight.no-chars,
4823
+ .lyrics-line.pre-active
4824
+ .background-vocal-container
4825
+ .lyrics-syllable.highlight.no-chars,
4826
+ .lyrics-line.pre-active
4827
+ .background-vocal-container
4828
+ .lyrics-syllable.pre-highlight.no-chars {
4829
+ background-image:
4830
+ linear-gradient(
4831
+ 90deg,
4832
+ #ffffff00 0%,
4833
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 50%,
4834
+ #0000 100%
4835
+ ),
4836
+ linear-gradient(
4837
+ 90deg,
4838
+ color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 100%,
4839
+ #0000 100%
4840
+ );
4841
+ }
4842
+
4843
+ .lyrics-line.active
4844
+ .background-vocal-container
4845
+ .lyrics-syllable.highlight.rtl-text,
4846
+ .lyrics-line.active
4847
+ .background-vocal-container
4848
+ .lyrics-syllable.pre-highlight.rtl-text,
4849
+ .lyrics-line.pre-active
4850
+ .background-vocal-container
4851
+ .lyrics-syllable.highlight.rtl-text,
4852
+ .lyrics-line.pre-active
4853
+ .background-vocal-container
4854
+ .lyrics-syllable.pre-highlight.rtl-text {
4855
+ background-image:
4856
+ linear-gradient(
4857
+ -90deg,
4858
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 0%,
4859
+ transparent 100%
4860
+ ),
4861
+ linear-gradient(
4862
+ -90deg,
4863
+ color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 100%,
4864
+ transparent 100%
4865
+ );
4336
4866
  }
4337
4867
 
4338
4868
  /* Non-growable words float up with a gentle curve */
@@ -4340,18 +4870,88 @@ AmLyrics.styles = i$3 `
4340
4870
  .lyrics-word:not(.growable)
4341
4871
  .lyrics-syllable.highlight {
4342
4872
  transform: translateY(-3.5%);
4343
- transition:
4344
- transform var(--rise-duration, 1.5s) cubic-bezier(0.22, 1, 0.36, 1),
4345
- background-color 0.5s,
4346
- color 0.5s;
4873
+ }
4874
+
4875
+ .lyrics-line.persist-highlight:not(.lyrics-gap)
4876
+ .lyrics-word:not(.growable)
4877
+ .lyrics-syllable.finished {
4878
+ transform: translateY(-3.5%);
4347
4879
  }
4348
4880
 
4349
4881
  .lyrics-word.growable .lyrics-syllable.cleanup .char {
4350
4882
  transform: translateY(-3.5%);
4351
4883
  }
4352
4884
 
4353
- .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.highlight.finished {
4354
- background-image: none;
4885
+ .lyrics-word.char-rise .lyrics-syllable.cleanup .char {
4886
+ transform: translateY(-3.5%);
4887
+ }
4888
+
4889
+ .lyrics-line.persist-highlight
4890
+ .lyrics-word.growable
4891
+ .lyrics-syllable.finished
4892
+ .char,
4893
+ .lyrics-line.persist-highlight
4894
+ .lyrics-word.char-rise
4895
+ .lyrics-syllable.finished
4896
+ .char {
4897
+ transform: translateY(-3.5%);
4898
+ }
4899
+
4900
+ /* Background vocal overrides — placed AFTER main rules so they win
4901
+ on equal specificity. */
4902
+ .background-vocal-container .lyrics-syllable {
4903
+ background-color: color-mix(
4904
+ in srgb,
4905
+ var(--lyplus-text-secondary) 50%,
4906
+ #888888
4907
+ );
4908
+ }
4909
+
4910
+ .lyrics-line.active:not(.lyrics-gap)
4911
+ .background-vocal-container
4912
+ .lyrics-syllable.finished,
4913
+ .lyrics-line.pre-active
4914
+ .background-vocal-container
4915
+ .lyrics-syllable.finished {
4916
+ background-color: color-mix(
4917
+ in srgb,
4918
+ var(--lyplus-text-primary) 50%,
4919
+ #888888
4920
+ );
4921
+ }
4922
+
4923
+ .background-vocal-container .lyrics-syllable.line-synced {
4924
+ color: color-mix(
4925
+ in srgb,
4926
+ var(--lyplus-text-secondary) 50%,
4927
+ #888888
4928
+ ) !important;
4929
+ }
4930
+
4931
+ .lyrics-line.active:not(.lyrics-gap)
4932
+ .background-vocal-container
4933
+ .lyrics-syllable.line-synced,
4934
+ .lyrics-line.pre-active
4935
+ .background-vocal-container
4936
+ .lyrics-syllable.line-synced {
4937
+ color: color-mix(
4938
+ in srgb,
4939
+ var(--lyplus-text-primary) 50%,
4940
+ #888888
4941
+ ) !important;
4942
+ }
4943
+
4944
+ .lyrics-line.active:not(.lyrics-gap)
4945
+ .background-vocal-container
4946
+ .lyrics-syllable.line-synced.finished,
4947
+ .lyrics-line.pre-active
4948
+ .background-vocal-container
4949
+ .lyrics-syllable.line-synced.finished {
4950
+ color: color-mix(
4951
+ in srgb,
4952
+ var(--lyplus-text-primary) 50%,
4953
+ #888888
4954
+ ) !important;
4355
4955
  }
4356
4956
 
4357
4957
  .lyrics-syllable.pre-highlight {
@@ -4395,8 +4995,11 @@ AmLyrics.styles = i$3 `
4395
4995
  }
4396
4996
 
4397
4997
  .lyrics-syllable.finished span.char {
4398
- transition: color 0.18s;
4399
4998
  background-color: var(--lyplus-text-primary);
4999
+ transition:
5000
+ color 0.7s,
5001
+ background-color 0.7s,
5002
+ transform 0.7s ease;
4400
5003
  }
4401
5004
 
4402
5005
  /* Active char spans: structural only, wipe animation sets gradient */
@@ -4587,7 +5190,7 @@ AmLyrics.styles = i$3 `
4587
5190
  position: relative;
4588
5191
  box-sizing: border-box;
4589
5192
  font-weight: normal;
4590
- transform: translateY(var(--lyrics-scroll-offset, 0px)) translateZ(1px);
5193
+ transform: translateY(var(--lyrics-scroll-offset, 0px));
4591
5194
  transition:
4592
5195
  opacity 0.3s ease,
4593
5196
  transform 0.6s cubic-bezier(0.23, 1, 0.32, 1)
@@ -4598,7 +5201,7 @@ AmLyrics.styles = i$3 `
4598
5201
  .lyrics-plus-empty {
4599
5202
  display: block;
4600
5203
  height: 100vh;
4601
- transform: translateY(var(--lyrics-scroll-offset, 0px)) translateZ(1px);
5204
+ transform: translateY(var(--lyrics-scroll-offset, 0px));
4602
5205
  }
4603
5206
 
4604
5207
  .lyrics-footer {
@@ -4607,8 +5210,8 @@ AmLyrics.styles = i$3 `
4607
5210
  align-items: center;
4608
5211
  flex-wrap: wrap;
4609
5212
  text-align: left;
4610
- font-size: 1.2em;
4611
- color: rgba(255, 255, 255, 0.6);
5213
+ font-size: calc(var(--lyplus-font-size-base) * 0.5);
5214
+ color: var(--lyplus-text-secondary);
4612
5215
  padding: 20px 0 50vh 0;
4613
5216
  margin-top: 10px;
4614
5217
  font-weight: 400;
@@ -4621,9 +5224,9 @@ AmLyrics.styles = i$3 `
4621
5224
  }
4622
5225
 
4623
5226
  .lyrics-footer.lyrics-line {
4624
- font-size: 1.2em;
5227
+ font-size: calc(var(--lyplus-font-size-base) * 0.5);
4625
5228
  padding: 20px var(--lyplus-padding-line) 50vh var(--lyplus-padding-line);
4626
- cursor: default;
5229
+ margin-top: 0;
4627
5230
  }
4628
5231
 
4629
5232
  .lyrics-footer.active {
@@ -4911,7 +5514,7 @@ AmLyrics.styles = i$3 `
4911
5514
  0% 100%;
4912
5515
  background-position:
4913
5516
  calc(100% + 0.375em) 0%,
4914
- calc(100% + 0.36em);
5517
+ calc(100% + 0.36em) 0%;
4915
5518
  }
4916
5519
  to {
4917
5520
  background-size:
@@ -4919,7 +5522,7 @@ AmLyrics.styles = i$3 `
4919
5522
  100% 100%;
4920
5523
  background-position:
4921
5524
  -0.75em 0%,
4922
- right;
5525
+ right 0%;
4923
5526
  }
4924
5527
  }
4925
5528
 
@@ -4930,7 +5533,7 @@ AmLyrics.styles = i$3 `
4930
5533
  0% 100%;
4931
5534
  background-position:
4932
5535
  calc(100% + 0.75em) 0%,
4933
- calc(100% + 0.5em);
5536
+ calc(100% + 0.5em) 0%;
4934
5537
  }
4935
5538
  100% {
4936
5539
  background-size:
@@ -4938,7 +5541,7 @@ AmLyrics.styles = i$3 `
4938
5541
  100% 100%;
4939
5542
  background-position:
4940
5543
  -0.75em 0%,
4941
- right;
5544
+ right 0%;
4942
5545
  }
4943
5546
  }
4944
5547
 
@@ -4968,7 +5571,7 @@ AmLyrics.styles = i$3 `
4968
5571
  0% 100%;
4969
5572
  background-position:
4970
5573
  calc(100% + 0.75em) 0%,
4971
- right;
5574
+ right 0%;
4972
5575
  }
4973
5576
  to {
4974
5577
  background-size:
@@ -4976,7 +5579,7 @@ AmLyrics.styles = i$3 `
4976
5579
  0% 100%;
4977
5580
  background-position:
4978
5581
  calc(100% + 0.375em) 0%,
4979
- right;
5582
+ right 0%;
4980
5583
  }
4981
5584
  }
4982
5585
 
@@ -5042,7 +5645,7 @@ AmLyrics.styles = i$3 `
5042
5645
  }
5043
5646
 
5044
5647
  /* Character grow animation — translate3d+scale3d for smooth transform,
5045
- drop-shadow for glow (text-shadow doesn't work with background-clip:text) */
5648
+ drop-shadow for glow */
5046
5649
  @keyframes grow-dynamic {
5047
5650
  0% {
5048
5651
  transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
@@ -5079,6 +5682,16 @@ AmLyrics.styles = i$3 `
5079
5682
  }
5080
5683
  }
5081
5684
 
5685
+ @keyframes rise-char {
5686
+ 0% {
5687
+ transform: translate3d(0, 0, 0);
5688
+ }
5689
+ 65%,
5690
+ 100% {
5691
+ transform: translate3d(0, var(--char-rise-y, -1.12px), 0);
5692
+ }
5693
+ }
5694
+
5082
5695
  @keyframes grow-static {
5083
5696
  0%,
5084
5697
  100% {