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..ede95f497f 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,20 @@ const UnsyncedLyricsPage = react.memo(({ lyrics, provider, copyright }) => { .catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard")); }, }, + (() => { + if (!CONFIG.visual["show-performers"] || !performer) return null; + + 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..a456f98495 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,130 @@ const ProviderMusixmatch = (() => { return body; } + 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 = []; + 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, + }; + }) + .filter((p) => p.name !== "Unknown"); + + const names = resolvedPerformers.map((p) => p.name); + 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 +248,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 +269,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 +310,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 +349,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); */ +}