Skip to content

Commit 404bb10

Browse files
authored
feat(lyrics-plus): add performer tag for the Musixmatch provider (#3689)
1 parent b727696 commit 404bb10

File tree

5 files changed

+251
-17
lines changed

5 files changed

+251
-17
lines changed

CustomApps/lyrics-plus/OptionsMenu.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation, musixmat
351351
);
352352
});
353353

354-
const AdjustmentsMenu = react.memo(({ mode }) => {
354+
const AdjustmentsMenu = react.memo(({ mode, hasPerformer }) => {
355355
return react.createElement(
356356
Spicetify.ReactComponent.TooltipWrapper,
357357
{
@@ -394,6 +394,12 @@ const AdjustmentsMenu = react.memo(({ mode }) => {
394394
type: ConfigSlider,
395395
when: () => mode === SYNCED || mode === KARAOKE,
396396
},
397+
{
398+
desc: "Show performers",
399+
key: "show-performers",
400+
type: ConfigSlider,
401+
when: () => hasPerformer && (mode === SYNCED || mode === KARAOKE || mode === UNSYNCED),
402+
},
397403
{
398404
desc: "Dual panel",
399405
key: "dual-genius",

CustomApps/lyrics-plus/Pages.js

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,18 @@ const useTrackPosition = (callback) => {
5656
}, [callbackRef]);
5757
};
5858

59-
const KaraokeLine = ({ text, isActive, position, startTime }) => {
60-
if (!isActive) {
59+
const KaraokeLine = ({ text, isActive, position, startTime, endTime }) => {
60+
if (endTime && position > endTime) {
6161
return text.map(({ word }) => word).join("");
6262
}
6363

64-
return text.map(({ word, time }) => {
64+
return text.map(({ word, time }, i) => {
6565
const isWordActive = position >= startTime;
6666
startTime += time;
6767
return react.createElement(
6868
"span",
6969
{
70+
key: i,
7071
className: `lyrics-lyricsContainer-Karaoke-Word${isWordActive ? " lyrics-lyricsContainer-Karaoke-WordActive" : ""}`,
7172
style: {
7273
"--word-duration": `${time}ms`,
@@ -138,7 +139,7 @@ const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara
138139
},
139140
key: lyricsId,
140141
},
141-
activeLines.map(({ text, lineNumber, startTime, originalText }, i) => {
142+
activeLines.map(({ text, lineNumber, startTime, endTime, originalText, performer }, i) => {
142143
if (i === 1 && activeLineIndex === 1) {
143144
return react.createElement(IdlingIndicator, {
144145
progress: position / activeLines[2].startTime,
@@ -207,7 +208,23 @@ const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara
207208
.catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard"));
208209
},
209210
},
210-
!isKara ? lineText : react.createElement(KaraokeLine, { text, startTime, position, isActive })
211+
(() => {
212+
if (!CONFIG.visual["show-performers"] || !performer) return null;
213+
214+
if (!CONFIG.visual["synced-compact"]) {
215+
const previousLine = lyricWithEmptyLines[lineNumber - 1];
216+
if (previousLine && previousLine.performer === performer) return null;
217+
}
218+
219+
return react.createElement(
220+
"span",
221+
{
222+
className: "lyrics-lyricsContainer-Performer",
223+
},
224+
performer
225+
);
226+
})(),
227+
!isKara ? lineText : react.createElement(KaraokeLine, { text, startTime, endTime, position, isActive })
211228
),
212229
belowMode &&
213230
react.createElement(
@@ -439,7 +456,7 @@ const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKa
439456
react.createElement("p", {
440457
className: "lyrics-lyricsContainer-LyricsUnsyncedPadding",
441458
}),
442-
padded.map(({ text, startTime, originalText }, i) => {
459+
padded.map(({ text, startTime, endTime, originalText, performer }, i) => {
443460
if (i === 0) {
444461
return react.createElement(IdlingIndicator, {
445462
isActive: activeLineIndex === 0,
@@ -486,7 +503,23 @@ const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKa
486503
.catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard"));
487504
},
488505
},
489-
!isKara ? lineText : react.createElement(KaraokeLine, { text, startTime, position, isActive })
506+
(() => {
507+
if (!CONFIG.visual["show-performers"] || !performer) return null;
508+
509+
if (!CONFIG.visual["synced-compact"]) {
510+
const previousLine = padded[i - 1];
511+
if (previousLine && previousLine.performer === performer) return null;
512+
}
513+
514+
return react.createElement(
515+
"span",
516+
{
517+
className: "lyrics-lyricsContainer-Performer",
518+
},
519+
performer
520+
);
521+
})(),
522+
!isKara ? lineText : react.createElement(KaraokeLine, { text, startTime, endTime, position, isActive })
490523
),
491524
belowMode &&
492525
react.createElement(
@@ -524,7 +557,7 @@ const UnsyncedLyricsPage = react.memo(({ lyrics, provider, copyright }) => {
524557
react.createElement("p", {
525558
className: "lyrics-lyricsContainer-LyricsUnsyncedPadding",
526559
}),
527-
lyrics.map(({ text, originalText }, index) => {
560+
lyrics.map(({ text, originalText, performer }, index) => {
528561
const showTranslatedBelow = CONFIG.visual["translate:display-mode"] === "below";
529562
// If we have original text and we are showing translated below, we should show the original text
530563
// Otherwise we should show the translated text
@@ -553,6 +586,20 @@ const UnsyncedLyricsPage = react.memo(({ lyrics, provider, copyright }) => {
553586
.catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard"));
554587
},
555588
},
589+
(() => {
590+
if (!CONFIG.visual["show-performers"] || !performer) return null;
591+
592+
const previousLine = lyrics[index - 1];
593+
if (previousLine && previousLine.performer === performer) return null;
594+
595+
return react.createElement(
596+
"span",
597+
{
598+
className: "lyrics-lyricsContainer-Performer",
599+
},
600+
performer
601+
);
602+
})(),
556603
lineText
557604
),
558605
belowMode &&

CustomApps/lyrics-plus/ProviderMusixmatch.js

Lines changed: 174 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ const ProviderMusixmatch = (() => {
4949
q_duration: durr,
5050
f_subtitle_length: Math.floor(durr),
5151
usertoken: CONFIG.providers.musixmatch.token,
52-
part: "track_lyrics_translation_status",
52+
part: "track_lyrics_translation_status,track_structure,track_performer_tagging",
5353
};
5454

5555
const finalURL =
@@ -91,6 +91,130 @@ const ProviderMusixmatch = (() => {
9191
return body;
9292
}
9393

94+
function parsePerformerData(meta) {
95+
if (!meta || !meta.track || !meta.track.performer_tagging) {
96+
return [];
97+
}
98+
99+
const tagging = meta.track.performer_tagging;
100+
const miscTags = meta.track.performer_tagging_misc_tags || {};
101+
let performerMap = [];
102+
if (tagging && tagging.content && tagging.content.length > 0) {
103+
const resources = tagging.resources?.artists || [];
104+
const resourcesList = Array.isArray(resources) ? resources : Object.values(resources);
105+
106+
performerMap = tagging.content
107+
.map((c) => {
108+
if (!c.performers || c.performers.length === 0) return null;
109+
110+
const resolvedPerformers = c.performers
111+
.map((p) => {
112+
let name = "Unknown";
113+
if (p.type === "artist") {
114+
const fqid = p.fqid;
115+
const idFromFqid = fqid ? parseInt(fqid.split(":")[2]) : null;
116+
117+
const artist = resourcesList.find((r) => r.artist_id === idFromFqid);
118+
if (artist) name = artist.artist_name;
119+
} else if (miscTags[p.type]) {
120+
name = miscTags[p.type];
121+
}
122+
return {
123+
fqid: p.fqid,
124+
artist_id: p.fqid ? parseInt(p.fqid.split(":")[2]) : null,
125+
name: name,
126+
};
127+
})
128+
.filter((p) => p.name !== "Unknown");
129+
130+
const names = resolvedPerformers.map((p) => p.name);
131+
if (names.length === 0) return null;
132+
133+
return {
134+
name: names.join(", "),
135+
snippet: c.snippet,
136+
performers: resolvedPerformers,
137+
};
138+
})
139+
.filter(Boolean);
140+
}
141+
142+
const normalizeForMatch = (text) => text.replace(/\s+/g, "").toLowerCase();
143+
144+
const snippetQueue = [];
145+
if (performerMap.length > 0) {
146+
for (const tag of performerMap) {
147+
if (!tag.snippet) continue;
148+
const snippetLines = tag.snippet
149+
.split(/\n+/)
150+
.map((s) => s.trim())
151+
.filter(Boolean);
152+
for (const sLine of snippetLines) {
153+
if (sLine.length < 2 && !/^[\u3131-\uD79D]/.test(sLine)) continue;
154+
snippetQueue.push({
155+
text: normalizeForMatch(sLine),
156+
raw: sLine,
157+
performers: tag.performers,
158+
});
159+
}
160+
}
161+
}
162+
return snippetQueue;
163+
}
164+
165+
function matchSequential(lyricsLines, snippetQueue, getTextCallback = (l) => l.text) {
166+
if (!snippetQueue || snippetQueue.length === 0) return lyricsLines;
167+
168+
const normalizeForMatch = (text) => text.replace(/\s+/g, "").toLowerCase();
169+
let queueCursor = 0;
170+
const LOOKAHEAD = 5;
171+
172+
return lyricsLines.map((line) => {
173+
const lineText = getTextCallback(line) || "♪";
174+
let normalizedLine = normalizeForMatch(lineText);
175+
176+
let matchedPerformers = [];
177+
178+
while (queueCursor < snippetQueue.length) {
179+
let matchFoundAtOffset = -1;
180+
181+
for (let i = 0; i < LOOKAHEAD && queueCursor + i < snippetQueue.length; i++) {
182+
const snippet = snippetQueue[queueCursor + i];
183+
184+
if (normalizedLine.includes(snippet.text) && snippet.text.length > 0) {
185+
matchFoundAtOffset = i;
186+
break;
187+
}
188+
}
189+
190+
if (matchFoundAtOffset !== -1) {
191+
queueCursor += matchFoundAtOffset;
192+
const matchedSnippet = snippetQueue[queueCursor];
193+
matchedPerformers.push(...matchedSnippet.performers);
194+
normalizedLine = normalizedLine.replace(matchedSnippet.text, "");
195+
queueCursor++;
196+
} else {
197+
break;
198+
}
199+
}
200+
201+
const uniquePerformers = [];
202+
const sawMap = new Set();
203+
for (const p of matchedPerformers) {
204+
const key = p.fqid || p.name;
205+
if (!sawMap.has(key)) {
206+
sawMap.add(key);
207+
uniquePerformers.push(p);
208+
}
209+
}
210+
211+
return {
212+
...line,
213+
performers: uniquePerformers,
214+
};
215+
});
216+
}
217+
94218
async function getKaraoke(body) {
95219
const meta = body?.["matcher.track.get"]?.message?.body;
96220
if (!meta) {
@@ -124,6 +248,8 @@ const ProviderMusixmatch = (() => {
124248

125249
result = result.message.body;
126250

251+
const snippetQueue = parsePerformerData(meta);
252+
127253
const parsedKaraoke = JSON.parse(result.richsync.richsync_body).map((line) => {
128254
const startTime = line.ts * 1000;
129255
const endTime = line.te * 1000;
@@ -143,11 +269,26 @@ const ProviderMusixmatch = (() => {
143269
});
144270
return {
145271
startTime,
272+
endTime,
146273
text,
147274
};
148275
});
149276

150-
return parsedKaraoke;
277+
return matchSequential(parsedKaraoke, snippetQueue, (line) => {
278+
if (Array.isArray(line.text)) {
279+
return line.text.map((t) => t.word).join("");
280+
}
281+
return line.text;
282+
}).map((line) => {
283+
const performerNames = (line.performers || [])
284+
.map((p) => p.name)
285+
.filter(Boolean)
286+
.join(", ");
287+
return {
288+
...line,
289+
performer: performerNames || null,
290+
};
291+
});
151292
}
152293

153294
function getSynced(body) {
@@ -169,10 +310,22 @@ const ProviderMusixmatch = (() => {
169310
return null;
170311
}
171312

172-
return JSON.parse(subtitle.subtitle_body).map((line) => ({
173-
text: line.text || "♪",
174-
startTime: line.time.total * 1000,
175-
}));
313+
const snippetQueue = parsePerformerData(meta);
314+
const rawLines = JSON.parse(subtitle.subtitle_body);
315+
316+
return matchSequential(rawLines, snippetQueue, (l) => l.text).map((line) => {
317+
const lineText = line.text || "♪";
318+
const performerNames = (line.performers || [])
319+
.map((p) => p.name)
320+
.filter(Boolean)
321+
.join(", ");
322+
323+
return {
324+
text: lineText,
325+
startTime: line.time.total * 1000,
326+
performer: performerNames || null,
327+
};
328+
});
176329
}
177330

178331
return null;
@@ -196,7 +349,21 @@ const ProviderMusixmatch = (() => {
196349
if (!lyrics) {
197350
return null;
198351
}
199-
return lyrics.split("\n").map((text) => ({ text }));
352+
353+
const snippetQueue = parsePerformerData(meta);
354+
const rawLines = lyrics.split("\n").map((text) => ({ text }));
355+
356+
return matchSequential(rawLines, snippetQueue, (l) => l.text).map((line) => {
357+
const performerNames = (line.performers || [])
358+
.map((p) => p.name)
359+
.filter(Boolean)
360+
.join(", ");
361+
362+
return {
363+
...line,
364+
performer: performerNames || null,
365+
};
366+
});
200367
}
201368

202369
return null;

CustomApps/lyrics-plus/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const CONFIG = {
6363
"musixmatch-translation-language": localStorage.getItem("lyrics-plus:visual:musixmatch-translation-language") || "none",
6464
"fade-blur": getConfig("lyrics-plus:visual:fade-blur"),
6565
"fullscreen-key": localStorage.getItem("lyrics-plus:visual:fullscreen-key") || "f12",
66+
"show-performers": getConfig("lyrics-plus:visual:show-performers", true),
6667
"synced-compact": getConfig("lyrics-plus:visual:synced-compact"),
6768
"dual-genius": getConfig("lyrics-plus:visual:dual-genius"),
6869
"global-delay": Number(localStorage.getItem("lyrics-plus:visual:global-delay")) || 0,
@@ -1065,6 +1066,7 @@ class LyricsContainer extends react.Component {
10651066
const friendlyLanguage = lang && new Intl.DisplayNames(["en"], { type: "language" }).of(lang.split("-")[0])?.toLowerCase();
10661067
const hasMusixmatchLanguages = Array.isArray(this.state.musixmatchAvailableTranslations) && this.state.musixmatchAvailableTranslations.length > 0;
10671068
const hasTranslation = this.state.neteaseTranslation !== null || this.state.musixmatchTranslation !== null || hasMusixmatchLanguages;
1069+
const hasPerformer = !!this.state.currentLyrics?.some((line) => line.performer);
10681070

10691071
if (mode !== -1) {
10701072
showTranslationButton = (friendlyLanguage || hasTranslation) && (mode === SYNCED || mode === UNSYNCED);
@@ -1160,7 +1162,7 @@ class LyricsContainer extends react.Component {
11601162
musixmatchLanguages: this.state.musixmatchAvailableTranslations || [],
11611163
musixmatchSelectedLanguage: this.state.musixmatchTranslationLanguage || CONFIG.visual["musixmatch-translation-language"],
11621164
}),
1163-
react.createElement(AdjustmentsMenu, { mode }),
1165+
react.createElement(AdjustmentsMenu, { mode, hasPerformer }),
11641166
react.createElement(
11651167
Spicetify.ReactComponent.TooltipWrapper,
11661168
{

0 commit comments

Comments
 (0)