Skip to content
63 changes: 49 additions & 14 deletions CustomApps/lyrics-plus/OptionsMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,16 @@ const OptionsMenu = react.memo(({ options, onSelect, selected, defaultValue, bol
);
});

const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => {
function getMusixmatchTranslationPrefix() {
if (typeof window !== "undefined" && typeof window.__lyricsPlusMusixmatchTranslationPrefix === "string") {
return window.__lyricsPlusMusixmatchTranslationPrefix;
}

return "musixmatchTranslation:";
}

const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation, musixmatchLanguages, musixmatchSelectedLanguage }) => {
const musixmatchTranslationPrefix = getMusixmatchTranslationPrefix();
const items = useMemo(() => {
let sourceOptions = {
none: "None",
Expand All @@ -109,16 +118,20 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => {
none: "None",
};

if (hasTranslation.musixmatch) {
const selectedLanguage = CONFIG.visual["musixmatch-translation-language"];
if (selectedLanguage === "none") return;
const languageName = new Intl.DisplayNames([selectedLanguage], {
type: "language",
}).of(selectedLanguage);
sourceOptions = {
...sourceOptions,
musixmatchTranslation: `${languageName} (Musixmatch)`,
};
const musixmatchDisplay = new Intl.DisplayNames(["en"], { type: "language" });
const availableMusixmatchLanguages = Array.isArray(musixmatchLanguages) ? [...new Set(musixmatchLanguages.filter(Boolean))] : [];
const activeMusixmatchLanguage = musixmatchSelectedLanguage && musixmatchSelectedLanguage !== "none" ? musixmatchSelectedLanguage : null;
if (hasTranslation.musixmatch && activeMusixmatchLanguage) {
availableMusixmatchLanguages.push(activeMusixmatchLanguage);
}

if (availableMusixmatchLanguages.length) {
const musixmatchOptions = availableMusixmatchLanguages.reduce((acc, code) => {
const label = musixmatchDisplay.of(code) || code.toUpperCase();
acc[`${musixmatchTranslationPrefix}${code}`] = `${label} (Musixmatch)`;
return acc;
}, {});
sourceOptions = { ...sourceOptions, ...musixmatchOptions };
}

if (hasTranslation.netease) {
Expand Down Expand Up @@ -154,7 +167,7 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => {
}
}

return [
const configItems = [
{
desc: "Translation Provider",
key: "translate:translated-lyrics-source",
Expand Down Expand Up @@ -198,7 +211,16 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => {
when: () => friendlyLanguage,
},
];
}, [friendlyLanguage]);

return configItems;
}, [
friendlyLanguage,
hasTranslation.musixmatch,
hasTranslation.netease,
Array.isArray(musixmatchLanguages) ? musixmatchLanguages.join(",") : "",
musixmatchSelectedLanguage || "",
musixmatchTranslationPrefix,
]);

useEffect(() => {
// Currently opened Context Menu does not receive prop changes
Expand All @@ -210,7 +232,7 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => {
},
});
document.dispatchEvent(event);
}, [friendlyLanguage]);
}, [friendlyLanguage, items]);

return react.createElement(
Spicetify.ReactComponent.TooltipWrapper,
Expand Down Expand Up @@ -241,6 +263,19 @@ const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => {
CONFIG.visual["translate:translated-lyrics-source"] = "none";
localStorage.setItem(`${APP_NAME}:visual:translate:translated-lyrics-source`, "none");
}
if (name === "translate:translated-lyrics-source") {
let nextMusixmatchLanguage = null;
if (typeof value === "string" && value.startsWith(musixmatchTranslationPrefix)) {
nextMusixmatchLanguage = value.slice(musixmatchTranslationPrefix.length) || "none";
} else {
nextMusixmatchLanguage = "none";
}

if (nextMusixmatchLanguage !== null && CONFIG.visual["musixmatch-translation-language"] !== nextMusixmatchLanguage) {
CONFIG.visual["musixmatch-translation-language"] = nextMusixmatchLanguage;
localStorage.setItem(`${APP_NAME}:visual:musixmatch-translation-language`, nextMusixmatchLanguage);
}
}

CONFIG.visual[name] = value;
localStorage.setItem(`${APP_NAME}:visual:${name}`, value);
Expand Down
51 changes: 47 additions & 4 deletions CustomApps/lyrics-plus/ProviderMusixmatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,36 @@ const ProviderMusixmatch = (() => {
cookie: "x-mxm-token-guid=",
};

function findTranslationStatus(body) {
if (!body || typeof body !== "object") {
return null;
}

if (Array.isArray(body)) {
for (const item of body) {
const result = findTranslationStatus(item);
if (result) {
return result;
}
}

return null;
}

if (Array.isArray(body.track_lyrics_translation_status)) {
return body.track_lyrics_translation_status;
}

for (const value of Object.values(body)) {
const result = findTranslationStatus(value);
if (result) {
return result;
}
}

return null;
}

async function findLyrics(info) {
const baseURL =
"https://apic-desktop.musixmatch.com/ws/1.1/macro.subtitles.get?format=json&namespace=lyrics_richsynched&subtitle_format=mxm&app_id=web-desktop-app-v1.0&";
Expand All @@ -19,6 +49,7 @@ const ProviderMusixmatch = (() => {
q_duration: durr,
f_subtitle_length: Math.floor(durr),
usertoken: CONFIG.providers.musixmatch.token,
part: "track_lyrics_translation_status",
};

const finalURL =
Expand All @@ -44,6 +75,19 @@ const ProviderMusixmatch = (() => {
};
}

const translationStatus = findTranslationStatus(body);
const meta = body?.["matcher.track.get"]?.message?.body;
const availableTranslations = Array.isArray(translationStatus) ? [...new Set(translationStatus.map((status) => status?.to).filter(Boolean))] : [];

Object.defineProperties(body, {
__musixmatchTranslationStatus: {
value: availableTranslations,
},
__musixmatchTrackId: {
value: meta?.track?.track_id ?? null,
},
});

return body;
}

Expand Down Expand Up @@ -158,9 +202,8 @@ const ProviderMusixmatch = (() => {
return null;
}

async function getTranslation(body) {
const track_id = body?.["matcher.track.get"]?.message?.body?.track?.track_id;
if (!track_id) return null;
async function getTranslation(trackId) {
if (!trackId) return null;

const selectedLanguage = CONFIG.visual["musixmatch-translation-language"] || "none";
if (selectedLanguage === "none") return null;
Expand All @@ -169,7 +212,7 @@ const ProviderMusixmatch = (() => {
"https://apic-desktop.musixmatch.com/ws/1.1/crowd.track.translations.get?translation_fields_set=minimal&comment_format=text&format=json&app_id=web-desktop-app-v1.0&";

const params = {
track_id,
track_id: trackId,
selected_language: selectedLanguage,
usertoken: CONFIG.providers.musixmatch.token,
};
Expand Down
43 changes: 36 additions & 7 deletions CustomApps/lyrics-plus/Providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ const Providers = {
synced: null,
unsynced: null,
musixmatchTranslation: null,
musixmatchAvailableTranslations: [],
musixmatchTrackId: null,
musixmatchTranslationLanguage: null,
provider: "Musixmatch",
copyright: null,
};
Expand Down Expand Up @@ -81,14 +84,40 @@ const Providers = {
result.unsynced = unsynced;
result.copyright = list["track.lyrics.get"].message?.body?.lyrics?.lyrics_copyright?.trim();
}
const translation = await ProviderMusixmatch.getTranslation(list);
if ((synced || unsynced) && translation) {
result.musixmatchAvailableTranslations = Array.isArray(list.__musixmatchTranslationStatus) ? list.__musixmatchTranslationStatus : [];
result.musixmatchTrackId = list.__musixmatchTrackId ?? null;

const selectedLanguage = CONFIG.visual["musixmatch-translation-language"];
const canRequestTranslation =
selectedLanguage && selectedLanguage !== "none" && result.musixmatchAvailableTranslations.includes(selectedLanguage);

const translation = canRequestTranslation ? await ProviderMusixmatch.getTranslation(result.musixmatchTrackId) : null;
if ((synced || unsynced) && Array.isArray(translation) && translation.length) {
const normalizeLyrics =
typeof Utils !== "undefined" && typeof Utils.processLyrics === "function"
? (value) => Utils.processLyrics(value ?? "")
: (value) =>
typeof value === "string" ? value.replace(/ | /g, "").replace(/[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~?!,。、《》【】「」]/g, "") : "";

const translationMap = new Map();
for (const entry of translation) {
const normalizedMatched = normalizeLyrics(entry.matchedLine);
if (!translationMap.has(normalizedMatched)) {
translationMap.set(normalizedMatched, entry.translation);
}
}

const baseLyrics = synced ?? unsynced;
result.musixmatchTranslation = baseLyrics.map((line) => ({
...line,
text: translation.find((t) => t.matchedLine === line.text)?.translation ?? line.text,
originalText: line.text,
}));
result.musixmatchTranslation = baseLyrics.map((line) => {
const originalText = line.text;
const normalizedOriginal = normalizeLyrics(originalText);
return {
...line,
text: translationMap.get(normalizedOriginal) ?? line.text,
originalText,
};
});
result.musixmatchTranslationLanguage = selectedLanguage;
}

return result;
Expand Down
30 changes: 1 addition & 29 deletions CustomApps/lyrics-plus/Settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -547,17 +547,6 @@ const OptionList = ({ type, items, onChange }) => {
});
};

const languageCodes =
"none,en,af,ar,bg,bn,ca,zh,cs,da,de,el,es,et,fa,fi,fr,gu,he,hi,hr,hu,id,is,it,ja,jv,kn,ko,lt,lv,ml,mr,ms,nl,no,pl,pt,ro,ru,sk,sl,sr,su,sv,ta,te,th,tr,uk,ur,vi,zu".split(
","
);

const displayNames = new Intl.DisplayNames(["en"], { type: "language" });
const languageOptions = languageCodes.reduce((acc, code) => {
acc[code] = code === "none" ? "None" : displayNames.of(code);
return acc;
}, {});

function openConfig() {
const configContainer = react.createElement(
"div",
Expand Down Expand Up @@ -675,13 +664,6 @@ function openConfig() {
max: thresholdSizeLimit.max,
step: thresholdSizeLimit.step,
},
{
desc: "Musixmatch Translation Language.",
info: "Choose the language you want to translate the lyrics to. When the language is changed, the lyrics reloads.",
key: "musixmatch-translation-language",
type: ConfigSelection,
options: languageOptions,
},
{
desc: "Clear Memory Cache",
info: "Loaded lyrics are cached in memory for faster reloading. Press this button to clear the cached lyrics from memory without restarting Spotify.",
Expand All @@ -696,17 +678,7 @@ function openConfig() {
onChange: (name, value) => {
CONFIG.visual[name] = value;
localStorage.setItem(`${APP_NAME}:visual:${name}`, value);

// Reload Lyrics if translation language is changed
if (name === "musixmatch-translation-language") {
if (value === "none") {
CONFIG.visual["translate:translated-lyrics-source"] = "none";
localStorage.setItem(`${APP_NAME}:visual:translate:translated-lyrics-source`, "none");
}
reloadLyrics?.();
} else {
lyricContainerUpdate?.();
}
lyricContainerUpdate?.();

const configChange = new CustomEvent("lyrics-plus", {
detail: {
Expand Down
Loading