@uimaxbai/am-lyrics 1.0.7 → 1.0.9

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.
@@ -62,6 +62,21 @@ export declare class AmLyrics extends LitElement {
62
62
  private static parseQueryMetadata;
63
63
  private static searchLyricsPlusCatalog;
64
64
  private static fetchLyricsFromYouLyPlus;
65
+ /**
66
+ * Parse LRC subtitle format into LyricsLine[].
67
+ * Handles "[mm:ss.xx] text" lines.
68
+ */
69
+ private static parseLrcSubtitles;
70
+ /**
71
+ * Fetch lyrics from Tidal API.
72
+ * Picks 2 random servers, tries search + lyrics on each.
73
+ */
74
+ private static fetchLyricsFromTidal;
75
+ /**
76
+ * Fetch lyrics from LRCLIB.
77
+ * Uses search endpoint, prefers synced lyrics.
78
+ */
79
+ private static fetchLyricsFromLrclib;
65
80
  private static convertKPoeLyrics;
66
81
  private static toMilliseconds;
67
82
  firstUpdated(): void;
@@ -1 +1 @@
1
- {"version":3,"file":"AmLyrics.d.ts","sourceRoot":"","sources":["../../src/AmLyrics.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AmLyrics.d.ts","sourceRoot":"","sources":["../../src/AmLyrics.ts"],"names":[],"mappings":"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"}
@@ -310,16 +310,29 @@ class GoogleService {
310
310
  }
311
311
  }
312
312
 
313
- const VERSION = '1.0.7';
313
+ const VERSION = '1.0.9';
314
314
  const INSTRUMENTAL_THRESHOLD_MS = 7000; // Show dots for gaps >= 7s
315
315
  const KPOE_SERVERS = [
316
316
  'https://lyricsplus.binimum.org',
317
+ 'https://lyricsplus.atomix.one',
318
+ 'https://lyricsplus-seven.vercel.app',
317
319
  'https://lyricsplus.prjktla.workers.dev',
318
320
  'https://lyrics-plus-backend.vercel.app',
319
- 'https://lyricsplus.onrender.com',
320
- 'https://lyricsplus.prjktla.online',
321
321
  ];
322
322
  const DEFAULT_KPOE_SOURCE_ORDER = 'apple,lyricsplus,musixmatch,spotify,musixmatch-word';
323
+ const TIDAL_SERVERS = [
324
+ 'https://arran.monochrome.tf',
325
+ 'https://api.monochrome.tf/',
326
+ 'https://triton.squid.wtf',
327
+ 'https://wolf.qqdl.site',
328
+ 'https://maus.qqdl.site',
329
+ 'https://vogel.qqdl.site',
330
+ 'https://katze.qqdl.site',
331
+ 'https://hund.qqdl.site',
332
+ 'https://tidal.kinoplus.online',
333
+ 'https://hifi-one.spotisaver.net',
334
+ 'https://hifi-two.spotisaver.net',
335
+ ];
323
336
  class AmLyrics extends i {
324
337
  constructor() {
325
338
  super(...arguments);
@@ -466,6 +479,26 @@ class AmLyrics extends i {
466
479
  return;
467
480
  }
468
481
  }
482
+ // Fallback: Tidal
483
+ if (resolvedMetadata?.metadata) {
484
+ const tidalResult = await AmLyrics.fetchLyricsFromTidal(resolvedMetadata.metadata, resolvedMetadata.catalogIsrc);
485
+ if (tidalResult && tidalResult.lines.length > 0) {
486
+ this.lyrics = tidalResult.lines;
487
+ this.lyricsSource = 'Tidal';
488
+ await this.onLyricsLoaded();
489
+ return;
490
+ }
491
+ }
492
+ // Fallback: LRCLIB
493
+ if (resolvedMetadata?.metadata) {
494
+ const lrclibResult = await AmLyrics.fetchLyricsFromLrclib(resolvedMetadata.metadata);
495
+ if (lrclibResult && lrclibResult.lines.length > 0) {
496
+ this.lyrics = lrclibResult.lines;
497
+ this.lyricsSource = 'LRCLIB';
498
+ await this.onLyricsLoaded();
499
+ return;
500
+ }
501
+ }
469
502
  this.lyrics = undefined;
470
503
  this.lyricsSource = null;
471
504
  }
@@ -654,7 +687,9 @@ class AmLyrics extends i {
654
687
  }
655
688
  params.append('source', DEFAULT_KPOE_SOURCE_ORDER);
656
689
  let fallbackResult = null;
657
- for (const base of KPOE_SERVERS) {
690
+ // Shuffle servers so we pick a random one first, with all others as fallback
691
+ const shuffledServers = [...KPOE_SERVERS].sort(() => Math.random() - 0.5);
692
+ for (const base of shuffledServers) {
658
693
  const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
659
694
  const url = `${normalizedBase}/v2/lyrics/get?${params.toString()}`;
660
695
  let payload = null;
@@ -689,6 +724,196 @@ class AmLyrics extends i {
689
724
  }
690
725
  return fallbackResult;
691
726
  }
727
+ /**
728
+ * Parse LRC subtitle format into LyricsLine[].
729
+ * Handles "[mm:ss.xx] text" lines.
730
+ */
731
+ static parseLrcSubtitles(lrc) {
732
+ if (!lrc || typeof lrc !== 'string')
733
+ return [];
734
+ const lines = [];
735
+ const rawLines = lrc.split('\n');
736
+ const parsed = [];
737
+ for (const raw of rawLines) {
738
+ const match = raw.match(/^\[(\d{1,3}):(\d{2})\.(\d{2,3})\]\s?(.*)$/);
739
+ if (!match) {
740
+ // Skip non-timestamped lines (headers like [ti:], [ar:], etc.)
741
+ // eslint-disable-next-line no-continue
742
+ continue;
743
+ }
744
+ const minutes = parseInt(match[1], 10);
745
+ const seconds = parseInt(match[2], 10);
746
+ let centiseconds = parseInt(match[3], 10);
747
+ // Handle both mm:ss.xx (centiseconds) and mm:ss.xxx (milliseconds)
748
+ if (match[3].length === 3) {
749
+ centiseconds = Math.round(centiseconds / 10);
750
+ }
751
+ const timestamp = (minutes * 60 + seconds) * 1000 + centiseconds * 10;
752
+ const text = match[4] || '';
753
+ parsed.push({ timestamp, text });
754
+ }
755
+ for (let i = 0; i < parsed.length; i += 1) {
756
+ const { timestamp, text } = parsed[i];
757
+ // Endtime is the start of the next line, or timestamp + 5s for the last line
758
+ const endtime = i + 1 < parsed.length ? parsed[i + 1].timestamp : timestamp + 5000;
759
+ // Skip empty lines (instrumental gaps)
760
+ if (!text.trim()) {
761
+ // eslint-disable-next-line no-continue
762
+ continue;
763
+ }
764
+ const syllable = {
765
+ text,
766
+ part: false,
767
+ timestamp,
768
+ endtime,
769
+ lineSynced: true,
770
+ };
771
+ lines.push({
772
+ text: [syllable],
773
+ background: false,
774
+ backgroundText: [],
775
+ oppositeTurn: false,
776
+ timestamp,
777
+ endtime,
778
+ isWordSynced: false,
779
+ });
780
+ }
781
+ return lines;
782
+ }
783
+ /**
784
+ * Fetch lyrics from Tidal API.
785
+ * Picks 2 random servers, tries search + lyrics on each.
786
+ */
787
+ static async fetchLyricsFromTidal(metadata, isrc) {
788
+ const title = metadata.title?.trim();
789
+ const artist = metadata.artist?.trim();
790
+ if (!title || !artist)
791
+ return null;
792
+ // Pick 2 random unique servers
793
+ const shuffled = [...TIDAL_SERVERS].sort(() => Math.random() - 0.5);
794
+ const serversToTry = shuffled.slice(0, 2);
795
+ for (const base of serversToTry) {
796
+ try {
797
+ const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
798
+ // Step 1: Search for the track
799
+ const searchQuery = `${title} ${artist}`;
800
+ const searchParams = new URLSearchParams({ s: searchQuery });
801
+ // eslint-disable-next-line no-await-in-loop
802
+ const searchResponse = await fetch(`${normalizedBase}/search/?${searchParams.toString()}`);
803
+ if (!searchResponse.ok) {
804
+ // eslint-disable-next-line no-continue
805
+ continue;
806
+ }
807
+ // eslint-disable-next-line no-await-in-loop
808
+ const searchData = await searchResponse.json();
809
+ const items = searchData?.data?.items;
810
+ if (!Array.isArray(items) || items.length === 0) {
811
+ // eslint-disable-next-line no-continue
812
+ continue;
813
+ }
814
+ // Find best match: prefer ISRC match, then first result
815
+ let bestTrack = items[0];
816
+ if (isrc) {
817
+ const isrcMatch = items.find((item) => item.isrc && item.isrc.toLowerCase() === isrc.toLowerCase());
818
+ if (isrcMatch) {
819
+ bestTrack = isrcMatch;
820
+ }
821
+ }
822
+ const trackId = bestTrack?.id;
823
+ if (!trackId) {
824
+ // eslint-disable-next-line no-continue
825
+ continue;
826
+ }
827
+ // Step 2: Fetch lyrics
828
+ // eslint-disable-next-line no-await-in-loop
829
+ const lyricsResponse = await fetch(`${normalizedBase}/lyrics/?id=${trackId}`);
830
+ if (!lyricsResponse.ok) {
831
+ // eslint-disable-next-line no-continue
832
+ continue;
833
+ }
834
+ // eslint-disable-next-line no-await-in-loop
835
+ const lyricsData = await lyricsResponse.json();
836
+ const subtitles = lyricsData?.lyrics?.subtitles;
837
+ if (subtitles && typeof subtitles === 'string') {
838
+ const lines = AmLyrics.parseLrcSubtitles(subtitles);
839
+ if (lines.length > 0) {
840
+ const provider = lyricsData?.lyrics?.lyricsProvider || 'Tidal';
841
+ return {
842
+ lines,
843
+ source: `Tidal (${provider})`,
844
+ };
845
+ }
846
+ }
847
+ }
848
+ catch {
849
+ // Try next server
850
+ }
851
+ }
852
+ return null;
853
+ }
854
+ /**
855
+ * Fetch lyrics from LRCLIB.
856
+ * Uses search endpoint, prefers synced lyrics.
857
+ */
858
+ static async fetchLyricsFromLrclib(metadata) {
859
+ const title = metadata.title?.trim();
860
+ const artist = metadata.artist?.trim();
861
+ if (!title || !artist)
862
+ return null;
863
+ try {
864
+ const searchQuery = `${title} ${artist}`;
865
+ const params = new URLSearchParams({ q: searchQuery });
866
+ const response = await fetch(`https://lrclib.net/api/search?${params.toString()}`, {
867
+ headers: {
868
+ 'User-Agent': `apple-music-web-components/${VERSION}`,
869
+ },
870
+ });
871
+ if (!response.ok)
872
+ return null;
873
+ const results = await response.json();
874
+ if (!Array.isArray(results) || results.length === 0)
875
+ return null;
876
+ // Prefer results with synced lyrics
877
+ const withSynced = results.find((r) => r.syncedLyrics && typeof r.syncedLyrics === 'string');
878
+ const bestMatch = withSynced || results[0];
879
+ // Try synced lyrics first
880
+ if (bestMatch.syncedLyrics) {
881
+ const lines = AmLyrics.parseLrcSubtitles(bestMatch.syncedLyrics);
882
+ if (lines.length > 0) {
883
+ return { lines, source: 'LRCLIB' };
884
+ }
885
+ }
886
+ // Fall back to plain lyrics (unsynced)
887
+ if (bestMatch.plainLyrics && typeof bestMatch.plainLyrics === 'string') {
888
+ const plainLines = bestMatch.plainLyrics
889
+ .split('\n')
890
+ .filter((l) => l.trim());
891
+ if (plainLines.length > 0) {
892
+ const lines = plainLines.map((text) => ({
893
+ text: [
894
+ {
895
+ text,
896
+ part: false,
897
+ timestamp: 0,
898
+ endtime: 0,
899
+ },
900
+ ],
901
+ background: false,
902
+ backgroundText: [],
903
+ oppositeTurn: false,
904
+ timestamp: 0,
905
+ endtime: 0,
906
+ isWordSynced: false,
907
+ }));
908
+ return { lines, source: 'LRCLIB (unsynced)' };
909
+ }
910
+ }
911
+ }
912
+ catch {
913
+ // LRCLIB fetch failed
914
+ }
915
+ return null;
916
+ }
692
917
  static convertKPoeLyrics(payload) {
693
918
  if (!payload) {
694
919
  return null;
@@ -2265,28 +2490,160 @@ class AmLyrics extends i {
2265
2490
  wordGroups.push([syllable]);
2266
2491
  }
2267
2492
  }
2493
+ // Pre-compute isGrowable per "visual word": adjacent groups whose text
2494
+ // doesn't end with whitespace form one visual word (e.g. "a"+"live" = "alive").
2495
+ // We evaluate growable on the combined text/duration, then propagate
2496
+ // the result to each individual group so it renders through the
2497
+ // single-syllable path (which supports char-level glow).
2498
+ const groupGrowable = new Array(wordGroups.length).fill(false);
2499
+ // Visual word info for growable char-level glow:
2500
+ // Each group stores the combined visual word's text, duration, and
2501
+ // the char offset of this group within the visual word.
2502
+ const vwFullText = new Array(wordGroups.length).fill('');
2503
+ const vwFullDuration = new Array(wordGroups.length).fill(0);
2504
+ const vwCharOffset = new Array(wordGroups.length).fill(0);
2505
+ const vwStartMs = new Array(wordGroups.length).fill(0);
2506
+ const vwEndMs = new Array(wordGroups.length).fill(0);
2507
+ {
2508
+ let vwStart = 0;
2509
+ while (vwStart < wordGroups.length) {
2510
+ let vwEnd = vwStart;
2511
+ while (vwEnd < wordGroups.length - 1) {
2512
+ const grp = wordGroups[vwEnd];
2513
+ const lastText = grp[grp.length - 1].text;
2514
+ if (/\s$/.test(lastText))
2515
+ break;
2516
+ vwEnd += 1;
2517
+ }
2518
+ // Compute combined properties for this visual word
2519
+ const combinedText = wordGroups
2520
+ .slice(vwStart, vwEnd + 1)
2521
+ .flatMap(g => g.map(s => s.text))
2522
+ .join('')
2523
+ .trim();
2524
+ const combinedStart = wordGroups[vwStart][0].timestamp;
2525
+ const lastGrp = wordGroups[vwEnd];
2526
+ const combinedEnd = lastGrp[lastGrp.length - 1].endtime;
2527
+ const combinedDuration = combinedEnd - combinedStart;
2528
+ const isCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(combinedText);
2529
+ const isRTL = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF]/.test(combinedText);
2530
+ const hasHyphen = combinedText.includes('-');
2531
+ const isGrowableVW = !isCJK &&
2532
+ !isRTL &&
2533
+ !hasHyphen &&
2534
+ combinedText.length <= 7 &&
2535
+ combinedText.length > 0 &&
2536
+ combinedDuration >= 900 &&
2537
+ combinedDuration >= combinedText.length * 300 &&
2538
+ (combinedText.length >= 4 ||
2539
+ combinedDuration / combinedText.length >= 600);
2540
+ let charOff = 0;
2541
+ for (let gi = vwStart; gi <= vwEnd; gi += 1) {
2542
+ groupGrowable[gi] = isGrowableVW;
2543
+ vwFullText[gi] = combinedText;
2544
+ vwFullDuration[gi] = combinedDuration;
2545
+ vwCharOffset[gi] = charOff;
2546
+ vwStartMs[gi] = combinedStart;
2547
+ vwEndMs[gi] = combinedEnd;
2548
+ const grpText = wordGroups[gi].map(s => s.text).join('');
2549
+ charOff += grpText.replace(/\s/g, '').length;
2550
+ }
2551
+ vwStart = vwEnd + 1;
2552
+ }
2553
+ }
2268
2554
  // Create main vocals using YouLyPlus syllable structure
2269
2555
  const mainVocalElement = b `<p class="main-vocal-container">
2270
- ${wordGroups.map(group => {
2271
- // Compute combined text and timing for the whole word group
2272
- const groupText = group.map(s => s.text).join('');
2273
- const groupTrimmed = groupText.trim();
2274
- const groupStart = group[0].timestamp;
2275
- const groupEnd = group[group.length - 1].endtime;
2276
- const groupDuration = groupEnd - groupStart;
2556
+ ${wordGroups.map((group, groupIdx) => {
2557
+ const isGrowable = groupGrowable[groupIdx];
2558
+ // For growable visual words spanning multiple groups:
2559
+ // skip continuation groups (rendered by the first group)
2560
+ if (isGrowable && vwCharOffset[groupIdx] > 0) {
2561
+ return '';
2562
+ }
2277
2563
  // Check if ANY syllable in group is line-synced
2278
2564
  const groupLineSynced = group.some(s => s.lineSynced);
2279
- // YouLyPlus growable criteria applied to the FULL word
2280
- const isCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(groupTrimmed);
2281
- const isRTL = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF]/.test(groupTrimmed);
2282
- const hasHyphen = groupTrimmed.includes('-');
2283
- const isGrowable = !isCJK &&
2284
- !isRTL &&
2285
- !hasHyphen &&
2286
- groupTrimmed.length <= 7 &&
2287
- groupTrimmed.length > 0 &&
2288
- groupDuration >= 700 &&
2289
- groupDuration >= groupTrimmed.length * 400;
2565
+ // For growable multi-group visual words, combine all text
2566
+ // into one syllable so the wipe + glow animates as one unit
2567
+ if (isGrowable && vwFullText[groupIdx].length > 0) {
2568
+ const wordText = vwFullText[groupIdx];
2569
+ const wordDuration = vwFullDuration[groupIdx];
2570
+ const startTimeMs = vwStartMs[groupIdx];
2571
+ const endTimeMs = vwEndMs[groupIdx];
2572
+ const numChars = wordText.length;
2573
+ // Collect all text from groups in this visual word
2574
+ // by scanning forward while vwCharOffset is consecutive
2575
+ let combinedRawText = '';
2576
+ for (let gi = groupIdx; gi < wordGroups.length; gi += 1) {
2577
+ if (gi > groupIdx && vwCharOffset[gi] === 0)
2578
+ break;
2579
+ if (gi > groupIdx && !groupGrowable[gi])
2580
+ break;
2581
+ combinedRawText += wordGroups[gi].map(s => s.text).join('');
2582
+ }
2583
+ const syllableContent = b `${combinedRawText
2584
+ .split('')
2585
+ .map((char, charIndex) => {
2586
+ if (char === ' ') {
2587
+ return ' ';
2588
+ }
2589
+ const charStartPercent = charIndex / numChars;
2590
+ const minDuration = 1000;
2591
+ const maxDuration = 5000;
2592
+ const easingPower = 3;
2593
+ const progress = Math.min(1, Math.max(0, (wordDuration - minDuration) /
2594
+ (maxDuration - minDuration)));
2595
+ const easedProgress = progress ** easingPower;
2596
+ const isLongWord = numChars > 5;
2597
+ const isShortDuration = wordDuration < 1500;
2598
+ let maxDecayRate = 0;
2599
+ if (isLongWord || isShortDuration) {
2600
+ let decayStrength = 0;
2601
+ if (isLongWord)
2602
+ decayStrength += Math.min((numChars - 5) / 3, 1.0) * 0.4;
2603
+ if (isShortDuration)
2604
+ decayStrength +=
2605
+ Math.max(0, 1.0 - (wordDuration - 1000) / 500) * 0.4;
2606
+ maxDecayRate = Math.min(decayStrength, 0.85);
2607
+ }
2608
+ const positionInWord = numChars > 1 ? charIndex / (numChars - 1) : 0;
2609
+ const decayFactor = 1.0 - positionInWord * maxDecayRate;
2610
+ const charProgress = easedProgress * decayFactor;
2611
+ const baseGrowth = numChars <= 3 ? 0.07 : 0.05;
2612
+ const charMaxScale = 1.0 + baseGrowth + charProgress * 0.1;
2613
+ const charShadowIntensity = 0.4 + charProgress * 0.4;
2614
+ const normalizedGrowth = (charMaxScale - 1.0) / 0.13;
2615
+ const charTranslateYPeak = -normalizedGrowth * 6;
2616
+ const position = (charIndex + 0.5) / numChars;
2617
+ const horizontalOffset = (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25);
2618
+ return b `<span
2619
+ class="char"
2620
+ data-char-index="${charIndex}"
2621
+ data-syllable-char-index="${charIndex}"
2622
+ data-wipe-start="${charStartPercent.toFixed(4)}"
2623
+ data-wipe-duration="${(1 / numChars).toFixed(4)}"
2624
+ data-horizontal-offset="${horizontalOffset.toFixed(2)}"
2625
+ data-max-scale="${charMaxScale.toFixed(3)}"
2626
+ data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
2627
+ data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
2628
+ >${char}</span
2629
+ >`;
2630
+ })}`;
2631
+ return b `<span class="lyrics-word growable">
2632
+ <span class="lyrics-syllable-wrap">
2633
+ <span
2634
+ class="lyrics-syllable ${groupLineSynced
2635
+ ? 'line-synced'
2636
+ : ''}"
2637
+ data-start-time="${startTimeMs}"
2638
+ data-end-time="${endTimeMs}"
2639
+ data-duration="${wordDuration}"
2640
+ data-syllable-index="0"
2641
+ data-wipe-ratio="1"
2642
+ >${syllableContent}</span
2643
+ >
2644
+ </span>
2645
+ </span>`;
2646
+ }
2290
2647
  // For single-syllable groups, use original logic
2291
2648
  if (group.length === 1) {
2292
2649
  const syllable = group[0];
@@ -2311,7 +2668,7 @@ class AmLyrics extends i {
2311
2668
  >${syllable.romanizedText}</span
2312
2669
  >`
2313
2670
  : '';
2314
- // For growable words, wrap each character in a span with YouLyPlus applyGrowthStyles
2671
+ // For growable words (single-group visual word), use char glow
2315
2672
  const syllableContent = isGrowable
2316
2673
  ? b `${text.split('').map((char, charIndex) => {
2317
2674
  if (char === ' ') {
@@ -2319,14 +2676,12 @@ class AmLyrics extends i {
2319
2676
  }
2320
2677
  const numChars = trimmedText.length;
2321
2678
  const charStartPercent = charIndex / text.length;
2322
- // YouLyPlus emphasisMetrics calculation
2323
2679
  const minDuration = 1000;
2324
2680
  const maxDuration = 5000;
2325
2681
  const easingPower = 3;
2326
2682
  const progress = Math.min(1, Math.max(0, (durationMs - minDuration) /
2327
2683
  (maxDuration - minDuration)));
2328
2684
  const easedProgress = progress ** easingPower;
2329
- // Decay calculation for long/short words
2330
2685
  const isLongWord = numChars > 5;
2331
2686
  const isShortDuration = durationMs < 1500;
2332
2687
  let maxDecayRate = 0;
@@ -2340,7 +2695,6 @@ class AmLyrics extends i {
2340
2695
  Math.max(0, 1.0 - (durationMs - 1000) / 500) * 0.4;
2341
2696
  maxDecayRate = Math.min(decayStrength, 0.85);
2342
2697
  }
2343
- // Per-character calculations (exact YouLyPlus logic)
2344
2698
  const positionInWord = numChars > 1 ? charIndex / (numChars - 1) : 0;
2345
2699
  const decayFactor = 1.0 - positionInWord * maxDecayRate;
2346
2700
  const charProgress = easedProgress * decayFactor;
@@ -2349,10 +2703,8 @@ class AmLyrics extends i {
2349
2703
  const charShadowIntensity = 0.4 + charProgress * 0.4;
2350
2704
  const normalizedGrowth = (charMaxScale - 1.0) / 0.13;
2351
2705
  const charTranslateYPeak = -normalizedGrowth * 6;
2352
- // Horizontal offset (simplified - YouLyPlus uses actual text width measurement)
2353
2706
  const position = (charIndex + 0.5) / numChars;
2354
2707
  const horizontalOffset = (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25);
2355
- // MOVED TO DATA ATTRIBUTES and removed style attribute to avoid Lit conflict
2356
2708
  return b `<span
2357
2709
  class="char"
2358
2710
  data-char-index="${charIndex}"