@uimaxbai/am-lyrics 1.0.7 → 1.0.8

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