From e11046d99f1c840c201b320558dfa00568d8cede Mon Sep 17 00:00:00 2001 From: ianz56 Date: Tue, 3 Feb 2026 12:41:29 +0700 Subject: [PATCH 1/5] Add performer tag --- CustomApps/lyrics-plus/OptionsMenu.js | 8 +- CustomApps/lyrics-plus/Pages.js | 65 ++++++- CustomApps/lyrics-plus/ProviderMusixmatch.js | 175 ++++++++++++++++++- CustomApps/lyrics-plus/index.js | 4 +- CustomApps/lyrics-plus/style.css | 12 ++ 5 files changed, 247 insertions(+), 17 deletions(-) diff --git a/CustomApps/lyrics-plus/OptionsMenu.js b/CustomApps/lyrics-plus/OptionsMenu.js index 409cd7b0fb..ee07ec227f 100644 --- a/CustomApps/lyrics-plus/OptionsMenu.js +++ b/CustomApps/lyrics-plus/OptionsMenu.js @@ -351,7 +351,7 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation, musixmat ); }); -const AdjustmentsMenu = react.memo(({ mode }) => { +const AdjustmentsMenu = react.memo(({ mode, hasPerformer }) => { return react.createElement( Spicetify.ReactComponent.TooltipWrapper, { @@ -394,6 +394,12 @@ const AdjustmentsMenu = react.memo(({ mode }) => { type: ConfigSlider, when: () => mode === SYNCED || mode === KARAOKE, }, + { + desc: "Show performers", + key: "show-performers", + type: ConfigSlider, + when: () => hasPerformer && (mode === SYNCED || mode === KARAOKE || mode === UNSYNCED), + }, { desc: "Dual panel", key: "dual-genius", diff --git a/CustomApps/lyrics-plus/Pages.js b/CustomApps/lyrics-plus/Pages.js index 1b960236f6..fa236e914a 100755 --- a/CustomApps/lyrics-plus/Pages.js +++ b/CustomApps/lyrics-plus/Pages.js @@ -56,17 +56,18 @@ const useTrackPosition = (callback) => { }, [callbackRef]); }; -const KaraokeLine = ({ text, isActive, position, startTime }) => { - if (!isActive) { +const KaraokeLine = ({ text, isActive, position, startTime, endTime }) => { + if (endTime && position > endTime) { return text.map(({ word }) => word).join(""); } - return text.map(({ word, time }) => { + return text.map(({ word, time }, i) => { const isWordActive = position >= startTime; startTime += time; return react.createElement( "span", { + key: i, className: `lyrics-lyricsContainer-Karaoke-Word${isWordActive ? " lyrics-lyricsContainer-Karaoke-WordActive" : ""}`, style: { "--word-duration": `${time}ms`, @@ -138,7 +139,7 @@ const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara }, key: lyricsId, }, - activeLines.map(({ text, lineNumber, startTime, originalText }, i) => { + activeLines.map(({ text, lineNumber, startTime, endTime, originalText, performer }, i) => { if (i === 1 && activeLineIndex === 1) { return react.createElement(IdlingIndicator, { progress: position / activeLines[2].startTime, @@ -207,7 +208,23 @@ const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard")); }, }, - !isKara ? lineText : react.createElement(KaraokeLine, { text, startTime, position, isActive }) + (() => { + if (!CONFIG.visual["show-performers"] || !performer) return null; + + if (!CONFIG.visual["synced-compact"]) { + const previousLine = lyricWithEmptyLines[lineNumber - 1]; + if (previousLine && previousLine.performer === performer) return null; + } + + return react.createElement( + "span", + { + className: "lyrics-lyricsContainer-Performer", + }, + performer + ); + })(), + !isKara ? lineText : react.createElement(KaraokeLine, { text, startTime, endTime, position, isActive }) ), belowMode && react.createElement( @@ -439,7 +456,7 @@ const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKa react.createElement("p", { className: "lyrics-lyricsContainer-LyricsUnsyncedPadding", }), - padded.map(({ text, startTime, originalText }, i) => { + padded.map(({ text, startTime, endTime, originalText, performer }, i) => { if (i === 0) { return react.createElement(IdlingIndicator, { isActive: activeLineIndex === 0, @@ -486,7 +503,23 @@ const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKa .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard")); }, }, - !isKara ? lineText : react.createElement(KaraokeLine, { text, startTime, position, isActive }) + (() => { + if (!CONFIG.visual["show-performers"] || !performer) return null; + + if (!CONFIG.visual["synced-compact"]) { + const previousLine = padded[i - 1]; + if (previousLine && previousLine.performer === performer) return null; + } + + return react.createElement( + "span", + { + className: "lyrics-lyricsContainer-Performer", + }, + performer + ); + })(), + !isKara ? lineText : react.createElement(KaraokeLine, { text, startTime, endTime, position, isActive }) ), belowMode && react.createElement( @@ -524,7 +557,7 @@ const UnsyncedLyricsPage = react.memo(({ lyrics, provider, copyright }) => { react.createElement("p", { className: "lyrics-lyricsContainer-LyricsUnsyncedPadding", }), - lyrics.map(({ text, originalText }, index) => { + lyrics.map(({ text, originalText, performer }, index) => { const showTranslatedBelow = CONFIG.visual["translate:display-mode"] === "below"; // If we have original text and we are showing translated below, we should show the original text // Otherwise we should show the translated text @@ -553,6 +586,22 @@ const UnsyncedLyricsPage = react.memo(({ lyrics, provider, copyright }) => { .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard")); }, }, + (() => { + if (!CONFIG.visual["show-performers"] || !performer) return null; + + if (!CONFIG.visual["synced-compact"]) { + const previousLine = lyrics[index - 1]; + if (previousLine && previousLine.performer === performer) return null; + } + + return react.createElement( + "span", + { + className: "lyrics-lyricsContainer-Performer", + }, + performer + ); + })(), lineText ), belowMode && diff --git a/CustomApps/lyrics-plus/ProviderMusixmatch.js b/CustomApps/lyrics-plus/ProviderMusixmatch.js index 98c75d3d26..467329ba1b 100644 --- a/CustomApps/lyrics-plus/ProviderMusixmatch.js +++ b/CustomApps/lyrics-plus/ProviderMusixmatch.js @@ -49,7 +49,7 @@ const ProviderMusixmatch = (() => { q_duration: durr, f_subtitle_length: Math.floor(durr), usertoken: CONFIG.providers.musixmatch.token, - part: "track_lyrics_translation_status", + part: "track_lyrics_translation_status,track_structure,track_performer_tagging", }; const finalURL = @@ -91,6 +91,124 @@ const ProviderMusixmatch = (() => { return body; } + function parsePerformerData(meta) { + const tagging = meta.track.performer_tagging; + const miscTags = meta.track.performer_tagging_misc_tags || {}; + let performerMap = []; + if (tagging && tagging.content && tagging.content.length > 0) { + const resources = tagging.resources?.artists || []; + const resourcesList = Array.isArray(resources) ? resources : Object.values(resources); + + performerMap = tagging.content + .map((c) => { + if (!c.performers || c.performers.length === 0) return null; + + const resolvedPerformers = c.performers.map((p) => { + let name = "Unknown"; + if (p.type === "artist") { + const fqid = p.fqid; + const idFromFqid = fqid ? parseInt(fqid.split(":")[2]) : null; + + const artist = resourcesList.find((r) => r.artist_id === idFromFqid); + if (artist) name = artist.artist_name; + } else if (miscTags[p.type]) { + name = miscTags[p.type]; + } + return { + fqid: p.fqid, + artist_id: p.fqid ? parseInt(p.fqid.split(":")[2]) : null, + name: name, + }; + }); + + const names = resolvedPerformers.map((p) => p.name).filter((n) => n !== "Unknown"); + if (names.length === 0) return null; + + return { + name: names.join(", "), + snippet: c.snippet, + performers: resolvedPerformers, + }; + }) + .filter(Boolean); + } + + const normalizeForMatch = (text) => text.replace(/\s+/g, "").toLowerCase(); + + const snippetQueue = []; + if (performerMap.length > 0) { + for (const tag of performerMap) { + if (!tag.snippet) continue; + const snippetLines = tag.snippet + .split(/\n+/) + .map((s) => s.trim()) + .filter(Boolean); + for (const sLine of snippetLines) { + if (sLine.length < 2 && !/^[\u3131-\uD79D]/.test(sLine)) continue; + snippetQueue.push({ + text: normalizeForMatch(sLine), + raw: sLine, + performers: tag.performers, + }); + } + } + } + return snippetQueue; + } + + function matchSequential(lyricsLines, snippetQueue, getTextCallback = (l) => l.text) { + if (!snippetQueue || snippetQueue.length === 0) return lyricsLines; + + const normalizeForMatch = (text) => text.replace(/\s+/g, "").toLowerCase(); + let queueCursor = 0; + const LOOKAHEAD = 5; + + return lyricsLines.map((line) => { + const lineText = getTextCallback(line) || "♪"; + let normalizedLine = normalizeForMatch(lineText); + + let matchedPerformers = []; + + while (queueCursor < snippetQueue.length) { + let matchFoundAtOffset = -1; + + for (let i = 0; i < LOOKAHEAD && queueCursor + i < snippetQueue.length; i++) { + const snippet = snippetQueue[queueCursor + i]; + + if (normalizedLine.includes(snippet.text) && snippet.text.length > 0) { + matchFoundAtOffset = i; + break; + } + } + + if (matchFoundAtOffset !== -1) { + queueCursor += matchFoundAtOffset; + const matchedSnippet = snippetQueue[queueCursor]; + matchedPerformers.push(...matchedSnippet.performers); + normalizedLine = normalizedLine.replace(matchedSnippet.text, ""); + queueCursor++; + } else { + break; + } + } + + const uniquePerformers = []; + const sawMap = new Set(); + for (const p of matchedPerformers) { + const key = p.fqid || p.name; + if (!sawMap.has(key)) { + sawMap.add(key); + uniquePerformers.push(p); + } + } + + return { + ...line, + performers: uniquePerformers, + }; + }); + } + async function getKaraoke(body) { const meta = body?.["matcher.track.get"]?.message?.body; if (!meta) { @@ -124,6 +242,8 @@ const ProviderMusixmatch = (() => { result = result.message.body; + const snippetQueue = parsePerformerData(meta); + const parsedKaraoke = JSON.parse(result.richsync.richsync_body).map((line) => { const startTime = line.ts * 1000; const endTime = line.te * 1000; @@ -143,11 +263,26 @@ const ProviderMusixmatch = (() => { }); return { startTime, + endTime, text, }; }); - return parsedKaraoke; + return matchSequential(parsedKaraoke, snippetQueue, (line) => { + if (Array.isArray(line.text)) { + return line.text.map((t) => t.word).join(""); + } + return line.text; + }).map((line) => { + const performerNames = (line.performers || []) + .map((p) => p.name) + .filter(Boolean) + .join(", "); + return { + ...line, + performer: performerNames || null, + }; + }); } function getSynced(body) { @@ -169,10 +304,22 @@ const ProviderMusixmatch = (() => { return null; } - return JSON.parse(subtitle.subtitle_body).map((line) => ({ - text: line.text || "♪", - startTime: line.time.total * 1000, - })); + const snippetQueue = parsePerformerData(meta); + const rawLines = JSON.parse(subtitle.subtitle_body); + + return matchSequential(rawLines, snippetQueue, (l) => l.text).map((line) => { + const lineText = line.text || "♪"; + const performerNames = (line.performers || []) + .map((p) => p.name) + .filter(Boolean) + .join(", "); + + return { + text: lineText, + startTime: line.time.total * 1000, + performer: performerNames || null, + }; + }); } return null; @@ -196,7 +343,21 @@ const ProviderMusixmatch = (() => { if (!lyrics) { return null; } - return lyrics.split("\n").map((text) => ({ text })); + + const snippetQueue = parsePerformerData(meta); + const rawLines = lyrics.split("\n").map((text) => ({ text })); + + return matchSequential(rawLines, snippetQueue, (l) => l.text).map((line) => { + const performerNames = (line.performers || []) + .map((p) => p.name) + .filter(Boolean) + .join(", "); + + return { + ...line, + performer: performerNames || null, + }; + }); } return null; diff --git a/CustomApps/lyrics-plus/index.js b/CustomApps/lyrics-plus/index.js index b5840b33de..8c893ab86e 100644 --- a/CustomApps/lyrics-plus/index.js +++ b/CustomApps/lyrics-plus/index.js @@ -63,6 +63,7 @@ const CONFIG = { "musixmatch-translation-language": localStorage.getItem("lyrics-plus:visual:musixmatch-translation-language") || "none", "fade-blur": getConfig("lyrics-plus:visual:fade-blur"), "fullscreen-key": localStorage.getItem("lyrics-plus:visual:fullscreen-key") || "f12", + "show-performers": getConfig("lyrics-plus:visual:show-performers", true), "synced-compact": getConfig("lyrics-plus:visual:synced-compact"), "dual-genius": getConfig("lyrics-plus:visual:dual-genius"), "global-delay": Number(localStorage.getItem("lyrics-plus:visual:global-delay")) || 0, @@ -1065,6 +1066,7 @@ class LyricsContainer extends react.Component { const friendlyLanguage = lang && new Intl.DisplayNames(["en"], { type: "language" }).of(lang.split("-")[0])?.toLowerCase(); const hasMusixmatchLanguages = Array.isArray(this.state.musixmatchAvailableTranslations) && this.state.musixmatchAvailableTranslations.length > 0; const hasTranslation = this.state.neteaseTranslation !== null || this.state.musixmatchTranslation !== null || hasMusixmatchLanguages; + const hasPerformer = !!this.state.currentLyrics?.some((line) => line.performer); if (mode !== -1) { showTranslationButton = (friendlyLanguage || hasTranslation) && (mode === SYNCED || mode === UNSYNCED); @@ -1160,7 +1162,7 @@ class LyricsContainer extends react.Component { musixmatchLanguages: this.state.musixmatchAvailableTranslations || [], musixmatchSelectedLanguage: this.state.musixmatchTranslationLanguage || CONFIG.visual["musixmatch-translation-language"], }), - react.createElement(AdjustmentsMenu, { mode }), + react.createElement(AdjustmentsMenu, { mode, hasPerformer }), react.createElement( Spicetify.ReactComponent.TooltipWrapper, { diff --git a/CustomApps/lyrics-plus/style.css b/CustomApps/lyrics-plus/style.css index 298b2ddbe1..13c1b09217 100644 --- a/CustomApps/lyrics-plus/style.css +++ b/CustomApps/lyrics-plus/style.css @@ -306,6 +306,10 @@ div.lyrics-tabBar-headerItemLink { background-position: top left !important; } +.lyrics-lyricsContainer-LyricsLine:hover .lyrics-lyricsContainer-Karaoke-Word { + background-position: top left; +} + .lyrics-lyricsContainer-Karaoke-Word { color: var(--lyrics-color-inactive); background-image: linear-gradient( @@ -728,3 +732,11 @@ div.lyrics-tabBar-headerItemLink { margin-left: 100px; } } + +.lyrics-lyricsContainer-Performer { + display: block; + font-size: 0.6em; + opacity: 0.7; + line-height: 1.2em; + /* color: var(--lyrics-color-inactive); */ +} From 72e19a4b3f6f6b7fde5d966979d71793994056a7 Mon Sep 17 00:00:00 2001 From: ianz56 Date: Tue, 3 Feb 2026 13:04:58 +0700 Subject: [PATCH 2/5] add guard clause --- CustomApps/lyrics-plus-enhcance | 1 + CustomApps/lyrics-plus/ProviderMusixmatch.js | 4 ++++ 2 files changed, 5 insertions(+) create mode 160000 CustomApps/lyrics-plus-enhcance diff --git a/CustomApps/lyrics-plus-enhcance b/CustomApps/lyrics-plus-enhcance new file mode 160000 index 0000000000..39bfbf3e37 --- /dev/null +++ b/CustomApps/lyrics-plus-enhcance @@ -0,0 +1 @@ +Subproject commit 39bfbf3e373f47060291bbd96b4704d5d61c81c7 diff --git a/CustomApps/lyrics-plus/ProviderMusixmatch.js b/CustomApps/lyrics-plus/ProviderMusixmatch.js index 467329ba1b..df96bba4ab 100644 --- a/CustomApps/lyrics-plus/ProviderMusixmatch.js +++ b/CustomApps/lyrics-plus/ProviderMusixmatch.js @@ -92,6 +92,10 @@ const ProviderMusixmatch = (() => { } function parsePerformerData(meta) { + if (!meta || !meta.track || !meta.track.performer_tagging) { + return []; + } + const tagging = meta.track.performer_tagging; const miscTags = meta.track.performer_tagging_misc_tags || {}; let performerMap = []; From b03813790d857889f85c6873cb2e3af369882623 Mon Sep 17 00:00:00 2001 From: ianz56 Date: Tue, 3 Feb 2026 13:21:37 +0700 Subject: [PATCH 3/5] filter unknown --- CustomApps/lyrics-plus/ProviderMusixmatch.js | 40 ++++++++++---------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/CustomApps/lyrics-plus/ProviderMusixmatch.js b/CustomApps/lyrics-plus/ProviderMusixmatch.js index df96bba4ab..a456f98495 100644 --- a/CustomApps/lyrics-plus/ProviderMusixmatch.js +++ b/CustomApps/lyrics-plus/ProviderMusixmatch.js @@ -107,25 +107,27 @@ const ProviderMusixmatch = (() => { .map((c) => { if (!c.performers || c.performers.length === 0) return null; - const resolvedPerformers = c.performers.map((p) => { - let name = "Unknown"; - if (p.type === "artist") { - const fqid = p.fqid; - const idFromFqid = fqid ? parseInt(fqid.split(":")[2]) : null; - - const artist = resourcesList.find((r) => r.artist_id === idFromFqid); - if (artist) name = artist.artist_name; - } else if (miscTags[p.type]) { - name = miscTags[p.type]; - } - return { - fqid: p.fqid, - artist_id: p.fqid ? parseInt(p.fqid.split(":")[2]) : null, - name: name, - }; - }); - - const names = resolvedPerformers.map((p) => p.name).filter((n) => n !== "Unknown"); + const resolvedPerformers = c.performers + .map((p) => { + let name = "Unknown"; + if (p.type === "artist") { + const fqid = p.fqid; + const idFromFqid = fqid ? parseInt(fqid.split(":")[2]) : null; + + const artist = resourcesList.find((r) => r.artist_id === idFromFqid); + if (artist) name = artist.artist_name; + } else if (miscTags[p.type]) { + name = miscTags[p.type]; + } + return { + fqid: p.fqid, + artist_id: p.fqid ? parseInt(p.fqid.split(":")[2]) : null, + name: name, + }; + }) + .filter((p) => p.name !== "Unknown"); + + const names = resolvedPerformers.map((p) => p.name); if (names.length === 0) return null; return { From f7b9a2bdb0ee435c7e142087665464918727bf7e Mon Sep 17 00:00:00 2001 From: ianz56 Date: Mon, 16 Feb 2026 22:32:51 +0700 Subject: [PATCH 4/5] remove unnecessary folder --- CustomApps/lyrics-plus-enhcance | 1 - 1 file changed, 1 deletion(-) delete mode 160000 CustomApps/lyrics-plus-enhcance diff --git a/CustomApps/lyrics-plus-enhcance b/CustomApps/lyrics-plus-enhcance deleted file mode 160000 index 39bfbf3e37..0000000000 --- a/CustomApps/lyrics-plus-enhcance +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 39bfbf3e373f47060291bbd96b4704d5d61c81c7 From 8d12cbcc199610652340bf14306ee040a0ef3fe9 Mon Sep 17 00:00:00 2001 From: ianz56 Date: Mon, 16 Feb 2026 22:47:36 +0700 Subject: [PATCH 5/5] fix group performer tags in Unsynced mode --- CustomApps/lyrics-plus/Pages.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/CustomApps/lyrics-plus/Pages.js b/CustomApps/lyrics-plus/Pages.js index fa236e914a..ede95f497f 100755 --- a/CustomApps/lyrics-plus/Pages.js +++ b/CustomApps/lyrics-plus/Pages.js @@ -589,10 +589,8 @@ const UnsyncedLyricsPage = react.memo(({ lyrics, provider, copyright }) => { (() => { if (!CONFIG.visual["show-performers"] || !performer) return null; - if (!CONFIG.visual["synced-compact"]) { - const previousLine = lyrics[index - 1]; - if (previousLine && previousLine.performer === performer) return null; - } + const previousLine = lyrics[index - 1]; + if (previousLine && previousLine.performer === performer) return null; return react.createElement( "span",