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