// Run "npm i @types/react" to have this type package available in workspace
///
///
/** @type {React} */
const react = Spicetify.React;
const { useState, useEffect, useCallback, useMemo, useRef } = react;
/** @type {import("react").ReactDOM} */
const reactDOM = Spicetify.ReactDOM;
const spotifyVersion = Spicetify.Platform.version;
// Define a function called "render" to specify app entry point
// This function will be used to mount app to main view.
function render() {
return react.createElement(LyricsContainer, null);
}
function getConfig(name, defaultVal = true) {
const value = localStorage.getItem(name);
return value ? value === "true" : defaultVal;
}
const APP_NAME = "lyrics-plus";
const KARAOKE = 0;
const SYNCED = 1;
const UNSYNCED = 2;
const GENIUS = 3;
const CONFIG = {
visual: {
"playbar-button": getConfig("lyrics-plus:visual:playbar-button", false),
colorful: getConfig("lyrics-plus:visual:colorful"),
noise: getConfig("lyrics-plus:visual:noise"),
"background-color": localStorage.getItem("lyrics-plus:visual:background-color") || "var(--spice-main)",
"active-color": localStorage.getItem("lyrics-plus:visual:active-color") || "var(--spice-text)",
"inactive-color": localStorage.getItem("lyrics-plus:visual:inactive-color") || "rgba(var(--spice-rgb-subtext),0.5)",
"highlight-color": localStorage.getItem("lyrics-plus:visual:highlight-color") || "var(--spice-button)",
alignment: localStorage.getItem("lyrics-plus:visual:alignment") || "center",
"lines-before": localStorage.getItem("lyrics-plus:visual:lines-before") || "0",
"lines-after": localStorage.getItem("lyrics-plus:visual:lines-after") || "2",
"font-size": localStorage.getItem("lyrics-plus:visual:font-size") || "32",
"translate:translated-lyrics-source": localStorage.getItem("lyrics-plus:visual:translate:translated-lyrics-source") || "none",
"translate:detect-language-override": localStorage.getItem("lyrics-plus:visual:translate:detect-language-override") || "off",
"translation-mode:japanese": localStorage.getItem("lyrics-plus:visual:translation-mode:japanese") || "furigana",
"translation-mode:korean": localStorage.getItem("lyrics-plus:visual:translation-mode:korean") || "hangul",
"translation-mode:chinese": localStorage.getItem("lyrics-plus:visual:translation-mode:chinese") || "cn",
translate: getConfig("lyrics-plus:visual:translate", false),
"ja-detect-threshold": localStorage.getItem("lyrics-plus:visual:ja-detect-threshold") || "40",
"hans-detect-threshold": localStorage.getItem("lyrics-plus:visual:hans-detect-threshold") || "40",
"fade-blur": getConfig("lyrics-plus:visual:fade-blur"),
"fullscreen-key": localStorage.getItem("lyrics-plus:visual:fullscreen-key") || "f12",
"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,
delay: 0
},
providers: {
musixmatch: {
on: getConfig("lyrics-plus:provider:musixmatch:on"),
desc: "Fully compatible with Spotify. Requires a token that can be retrieved from the official Musixmatch app. If you have problems with retrieving lyrics, try refreshing the token by clicking Refresh Token
button.",
token: localStorage.getItem("lyrics-plus:provider:musixmatch:token") || "21051986b9886beabe1ce01c3ce94c96319411f8f2c122676365e3",
modes: [KARAOKE, SYNCED, UNSYNCED]
},
spotify: {
on: getConfig("lyrics-plus:provider:spotify:on"),
desc: "Lyrics sourced from official Spotify API.",
modes: [SYNCED, UNSYNCED]
},
netease: {
on: getConfig("lyrics-plus:provider:netease:on"),
desc: "Crowdsourced lyrics provider ran by Chinese developers and users.",
modes: [KARAOKE, SYNCED, UNSYNCED]
},
lrclib: {
on: getConfig("lyrics-plus:provider:lrclib:on"),
desc: "Lyrics sourced from lrclib.net. Supports both synced and unsynced lyrics. LRCLIB is a free and open-source lyrics provider.",
modes: [SYNCED, UNSYNCED]
},
genius: {
on: spotifyVersion >= "1.2.31" ? false : getConfig("lyrics-plus:provider:genius:on"),
desc: "Provide unsynced lyrics with insights from artists themselves. Genius is disabled and cannot be used as a provider on 1.2.31
and higher.",
modes: [GENIUS]
},
local: {
on: getConfig("lyrics-plus:provider:local:on"),
desc: "Provide lyrics from cache/local files loaded from previous Spotify sessions.",
modes: [KARAOKE, SYNCED, UNSYNCED]
}
},
providersOrder: localStorage.getItem("lyrics-plus:services-order"),
modes: ["karaoke", "synced", "unsynced", "genius"],
locked: localStorage.getItem("lyrics-plus:lock-mode") || "-1"
};
try {
CONFIG.providersOrder = JSON.parse(CONFIG.providersOrder);
if (!Array.isArray(CONFIG.providersOrder) || Object.keys(CONFIG.providers).length !== CONFIG.providersOrder.length) {
throw "";
}
} catch {
CONFIG.providersOrder = Object.keys(CONFIG.providers);
localStorage.setItem("lyrics-plus:services-order", JSON.stringify(CONFIG.providersOrder));
}
CONFIG.locked = Number.parseInt(CONFIG.locked);
CONFIG.visual["lines-before"] = Number.parseInt(CONFIG.visual["lines-before"]);
CONFIG.visual["lines-after"] = Number.parseInt(CONFIG.visual["lines-after"]);
CONFIG.visual["font-size"] = Number.parseInt(CONFIG.visual["font-size"]);
CONFIG.visual["ja-detect-threshold"] = Number.parseInt(CONFIG.visual["ja-detect-threshold"]);
CONFIG.visual["hans-detect-threshold"] = Number.parseInt(CONFIG.visual["hans-detect-threshold"]);
const CACHE = {};
const emptyState = {
karaoke: null,
synced: null,
unsynced: null,
genius: null,
genius2: null,
currentLyrics: null
};
let lyricContainerUpdate;
const fontSizeLimit = { min: 16, max: 256, step: 4 };
const thresholdSizeLimit = { min: 0, max: 100, step: 5 };
class LyricsContainer extends react.Component {
constructor() {
super();
this.state = {
karaoke: null,
synced: null,
unsynced: null,
genius: null,
genius2: null,
currentLyrics: null,
romaji: null,
furigana: null,
hiragana: null,
hangul: null,
romaja: null,
katakana: null,
cn: null,
hk: null,
tw: null,
musixmatchTranslation: null,
neteaseTranslation: null,
uri: "",
provider: "",
colors: {
background: "",
inactive: ""
},
tempo: "0.25s",
explicitMode: -1,
lockMode: CONFIG.locked,
mode: -1,
isLoading: false,
versionIndex: 0,
versionIndex2: 0,
isFullscreen: false,
isFADMode: false,
isCached: false
};
this.currentTrackUri = "";
this.nextTrackUri = "";
this.availableModes = [];
this.styleVariables = {};
this.fullscreenContainer = document.createElement("div");
this.fullscreenContainer.id = "lyrics-fullscreen-container";
this.mousetrap = new Spicetify.Mousetrap();
this.containerRef = react.createRef(null);
this.translator = new Translator(CONFIG.visual["translate:detect-language-override"]);
// Cache last state
this.translationProvider = CONFIG.visual["translate:translated-lyrics-source"];
this.languageOverride = CONFIG.visual["translate:detect-language-override"];
this.translate = CONFIG.visual.translate;
}
infoFromTrack(track) {
const meta = track?.metadata;
if (!meta) {
return null;
}
return {
duration: Number(meta.duration),
album: meta.album_title,
artist: meta.artist_name,
title: meta.title,
uri: track.uri,
image: meta.image_url
};
}
async fetchColors(uri) {
let vibrant = 0;
try {
try {
const { fetchExtractedColorForTrackEntity } = Spicetify.GraphQL.Definitions;
const { data } = await Spicetify.GraphQL.Request(fetchExtractedColorForTrackEntity, { uri });
const { hex } = data.trackUnion.albumOfTrack.coverArt.extractedColors.colorDark;
vibrant = Number.parseInt(hex.replace("#", ""), 16);
} catch {
const colors = await Spicetify.CosmosAsync.get(`https://spclient.wg.spotify.com/colorextractor/v1/extract-presets?uri=${uri}&format=json`);
vibrant = colors.entries[0].color_swatches.find(color => color.preset === "VIBRANT_NON_ALARMING").color;
}
} catch {
vibrant = 8747370;
}
this.setState({
colors: {
background: Utils.convertIntToRGB(vibrant),
inactive: Utils.convertIntToRGB(vibrant, 3)
}
});
}
async fetchTempo(uri) {
const audio = await Spicetify.CosmosAsync.get(`https://api.spotify.com/v1/audio-features/${uri.split(":")[2]}`);
let tempo = audio.tempo;
const MIN_TEMPO = 60;
const MAX_TEMPO = 150;
const MAX_PERIOD = 0.4;
if (!tempo) tempo = 105;
if (tempo < MIN_TEMPO) tempo = MIN_TEMPO;
if (tempo > MAX_TEMPO) tempo = MAX_TEMPO;
let period = MAX_PERIOD - ((tempo - MIN_TEMPO) / (MAX_TEMPO - MIN_TEMPO)) * MAX_PERIOD;
period = Math.round(period * 100) / 100;
this.setState({
tempo: `${String(period)}s`
});
}
async tryServices(trackInfo, mode = -1) {
const currentMode = CONFIG.modes[mode] || "";
let finalData = { ...emptyState, uri: trackInfo.uri };
for (const id of CONFIG.providersOrder) {
const service = CONFIG.providers[id];
if (spotifyVersion >= "1.2.31" && id === "genius") continue;
if (!service.on) continue;
if (mode !== -1 && !service.modes.includes(mode)) continue;
let data;
try {
data = await Providers[id](trackInfo);
} catch (e) {
console.error(e);
continue;
}
if (data.error || (!data.karaoke && !data.synced && !data.unsynced && !data.genius)) continue;
if (mode === -1) {
finalData = data;
CACHE[data.uri] = finalData;
return finalData;
}
if (!data[currentMode]) {
for (const key in data) {
if (!finalData[key]) {
finalData[key] = data[key];
}
}
continue;
}
for (const key in data) {
if (!finalData[key]) {
finalData[key] = data[key];
}
}
if (data.provider !== "local" && finalData.provider && finalData.provider !== data.provider) {
const styledMode = currentMode.charAt(0).toUpperCase() + currentMode.slice(1);
finalData.copyright = `${styledMode} lyrics provided by ${data.provider}\n${finalData.copyright || ""}`.trim();
}
if (finalData.musixmatchTranslation && typeof finalData.musixmatchTranslation[0].startTime === "undefined" && finalData.synced) {
finalData.musixmatchTranslation = finalData.synced.map(line => ({
...line,
text: finalData.musixmatchTranslation.find(l => Utils.processLyrics(l.originalText) === Utils.processLyrics(line.text))?.text ?? line.text
}));
}
CACHE[data.uri] = finalData;
return finalData;
}
CACHE[trackInfo.uri] = finalData;
return finalData;
}
async fetchLyrics(track, mode = -1) {
this.state.furigana =
this.state.romaji =
this.state.hiragana =
this.state.katakana =
this.state.hangul =
this.state.romaja =
this.state.cn =
this.state.hk =
this.state.tw =
this.state.musixmatchTranslation =
this.state.neteaseTranslation =
null;
const info = this.infoFromTrack(track);
if (!info) {
this.setState({ error: "No track info" });
return;
}
let isCached = this.lyricsSaved(info.uri);
if (CONFIG.visual.colorful) {
this.fetchColors(info.uri);
}
this.fetchTempo(info.uri);
if (mode !== -1) {
if (CACHE[info.uri]?.[CONFIG.modes[mode]]) {
this.resetDelay();
this.setState({ ...CACHE[info.uri], isCached });
{
let mode = -1;
if (this.state.explicitMode !== -1) {
mode = this.state.explicitMode;
} else if (this.state.lockMode !== -1) {
mode = this.state.lockMode;
} else {
// Auto switch
if (this.state.karaoke) {
mode = KARAOKE;
} else if (this.state.synced) {
mode = SYNCED;
} else if (this.state.unsynced) {
mode = UNSYNCED;
} else if (this.state.genius) {
mode = GENIUS;
}
}
const lyricsState = CACHE[info.uri][CONFIG.modes[mode]];
if (lyricsState) {
this.state.currentLyrics = this.state[CONFIG.visual["translate:translated-lyrics-source"]] ?? lyricsState;
}
}
this.translateLyrics();
return;
}
} else {
if (CACHE[info.uri]) {
this.resetDelay();
this.setState({ ...CACHE[info.uri], isCached });
{
let mode = -1;
if (this.state.explicitMode !== -1) {
mode = this.state.explicitMode;
} else if (this.state.lockMode !== -1) {
mode = this.state.lockMode;
} else {
// Auto switch
if (this.state.karaoke) {
mode = KARAOKE;
} else if (this.state.synced) {
mode = SYNCED;
} else if (this.state.unsynced) {
mode = UNSYNCED;
} else if (this.state.genius) {
mode = GENIUS;
}
}
const lyricsState = CACHE[info.uri][CONFIG.modes[mode]];
if (lyricsState) {
this.state.currentLyrics = this.state[CONFIG.visual["translate:translated-lyrics-source"]] ?? lyricsState;
}
}
this.translateLyrics();
return;
}
}
this.setState({ ...emptyState, isLoading: true, isCached: false });
const resp = await this.tryServices(info, mode);
isCached = this.lyricsSaved(resp.uri);
// In case user skips tracks too fast and multiple callbacks
// set wrong lyrics to current track.
if (resp.uri === this.currentTrackUri) {
this.resetDelay();
this.setState({ ...resp, isLoading: false, isCached });
}
this.translateLyrics();
}
lyricsSource(mode) {
const lyricsState = this.state[CONFIG.modes[mode]];
if (!lyricsState) return;
this.state.currentLyrics = this.state[CONFIG.visual["translate:translated-lyrics-source"]] ?? lyricsState;
}
provideLanguageCode(lyrics) {
if (!lyrics) return;
if (CONFIG.visual["translate:detect-language-override"] !== "off") return CONFIG.visual["translate:detect-language-override"];
return Utils.detectLanguage(lyrics);
}
async translateLyrics(silent = true) {
function showNotification(timeout) {
if (silent) return;
Spicetify.showNotification("Translating...", false, timeout);
}
const lyrics = this.state.currentLyrics;
const language = this.provideLanguageCode(lyrics);
if (!CONFIG.visual.translate || !language || typeof lyrics?.[0].text !== "string") return;
if (!this.translator?.finished[language.slice(0, 2)]) {
this.translator.injectExternals(language);
this.translator.createTranslator(language);
showNotification(500);
setTimeout(this.translateLyrics.bind(this), 100, false);
return;
}
// Seemingly long delay so it can be cleared later for accurate timing
showNotification(10000);
for (const params of [
["romaji", "spaced", "romaji"],
["hiragana", "furigana", "furigana"],
["hiragana", "normal", "hiragana"],
["katakana", "normal", "katakana"]
]) {
if (language !== "ja") continue;
Promise.all(lyrics.map(lyric => this.translator.romajifyText(lyric.text, params[0], params[1]))).then(results => {
const result = results.join("\n");
Utils.processTranslatedLyrics(result, lyrics, { state: this.state, stateName: params[2] });
showNotification(200);
lyricContainerUpdate?.();
});
}
for (const params of [
["hangul", "hangul"],
["romaja", "romaja"]
]) {
if (language !== "ko") continue;
Promise.all(lyrics.map(lyric => this.translator.convertToRomaja(lyric.text, params[1]))).then(results => {
const result = results.join("\n");
Utils.processTranslatedLyrics(result, lyrics, { state: this.state, stateName: params[1] });
showNotification(200);
lyricContainerUpdate?.();
});
}
for (const params of [
["cn", "hk"],
["cn", "tw"],
["t", "cn"],
["t", "hk"],
["t", "tw"]
]) {
if (!language.includes("zh") || (language === "zh-hans" && params[0] === "t") || (language === "zh-hant" && params[0] === "cn")) continue;
Promise.all(lyrics.map(lyric => this.translator.convertChinese(lyric.text, params[0], params[1]))).then(results => {
const result = results.join("\n");
Utils.processTranslatedLyrics(result, lyrics, { state: this.state, stateName: params[1] });
showNotification(200);
lyricContainerUpdate?.();
});
}
}
resetDelay() {
CONFIG.visual.delay = Number(localStorage.getItem(`lyrics-delay:${Spicetify.Player.data.item.uri}`)) || 0;
}
async onVersionChange(items, index) {
if (this.state.mode === GENIUS) {
this.setState({
...emptyLine,
genius2: this.state.genius2,
isLoading: true
});
const lyrics = await ProviderGenius.fetchLyricsVersion(items, index);
this.setState({
genius: lyrics,
versionIndex: index,
isLoading: false
});
}
}
async onVersionChange2(items, index) {
if (this.state.mode === GENIUS) {
this.setState({
...emptyLine,
genius: this.state.genius,
isLoading: true
});
const lyrics = await ProviderGenius.fetchLyricsVersion(items, index);
this.setState({
genius2: lyrics,
versionIndex2: index,
isLoading: false
});
}
}
saveLocalLyrics(uri, lyrics) {
if (lyrics.genius) {
lyrics.unsynced = lyrics.genius.split("
").map(lyc => {
return {
text: lyc.replace(/<[^>]*>/g, "")
};
});
lyrics.genius = null;
}
const localLyrics = JSON.parse(localStorage.getItem(`${APP_NAME}:local-lyrics`)) || {};
localLyrics[uri] = lyrics;
localStorage.setItem(`${APP_NAME}:local-lyrics`, JSON.stringify(localLyrics));
this.setState({ isCached: true });
}
lyricsSaved(uri) {
const localLyrics = JSON.parse(localStorage.getItem(`${APP_NAME}:local-lyrics`)) || {};
return !!localLyrics[uri];
}
processLyricsFromFile(event) {
const file = event.target.files;
if (!file.length) return;
const reader = new FileReader();
if (file[0].size > 1024 * 1024) {
Spicetify.showNotification("File too large", true);
return;
}
reader.onload = e => {
try {
const localLyrics = Utils.parseLocalLyrics(e.target.result);
const parsedKeys = Object.keys(localLyrics)
.filter(key => localLyrics[key])
.map(key => key[0].toUpperCase() + key.slice(1))
.map(key => `${key}`);
if (!parsedKeys.length) {
Spicetify.showNotification("Nothing to load", true);
return;
}
this.setState({ ...localLyrics, provider: "local" });
CACHE[this.currentTrackUri] = { ...localLyrics, provider: "local", uri: this.currentTrackUri };
this.saveLocalLyrics(this.currentTrackUri, localLyrics);
Spicetify.showNotification(`Loaded ${parsedKeys.join(", ")} lyrics from file`);
} catch (e) {
console.error(e);
Spicetify.showNotification("Failed to load lyrics", true);
}
};
reader.onerror = e => {
console.error(e);
Spicetify.showNotification("Failed to read file", true);
};
reader.readAsText(file[0]);
event.target.value = "";
}
componentDidMount() {
this.onQueueChange = async ({ data: queue }) => {
this.state.explicitMode = this.state.lockMode;
this.currentTrackUri = queue.current.uri;
this.fetchLyrics(queue.current, this.state.explicitMode);
this.viewPort.scrollTo(0, 0);
// Fetch next track
const nextTrack = queue.queued?.[0] || queue.nextUp?.[0];
const nextInfo = this.infoFromTrack(nextTrack);
// Debounce next track fetch
if (!nextInfo || nextInfo.uri === this.nextTrackUri) return;
this.nextTrackUri = nextInfo.uri;
this.tryServices(nextInfo, this.state.explicitMode);
};
if (Spicetify.Player?.data?.item) {
this.state.explicitMode = this.state.lockMode;
this.currentTrackUri = Spicetify.Player.data.item.uri;
this.fetchLyrics(Spicetify.Player.data.item, this.state.explicitMode);
}
this.updateVisualOnConfigChange();
Utils.addQueueListener(this.onQueueChange);
lyricContainerUpdate = () => {
this.updateVisualOnConfigChange();
this.forceUpdate();
};
this.viewPort =
document.querySelector(".Root__main-view .os-viewport") ?? document.querySelector(".Root__main-view .main-view-container__scroll-node");
this.configButton = new Spicetify.Menu.Item("Lyrics Plus config", false, openConfig, "lyrics");
this.configButton.register();
this.onFontSizeChange = event => {
if (!event.ctrlKey) return;
const dir = event.deltaY < 0 ? 1 : -1;
let temp = CONFIG.visual["font-size"] + dir * fontSizeLimit.step;
if (temp < fontSizeLimit.min) {
temp = fontSizeLimit.min;
} else if (temp > fontSizeLimit.max) {
temp = fontSizeLimit.max;
}
CONFIG.visual["font-size"] = temp;
localStorage.setItem("lyrics-plus:visual:font-size", temp);
lyricContainerUpdate();
};
this.toggleFullscreen = () => {
const isEnabled = !this.state.isFullscreen;
if (isEnabled) {
document.body.append(this.fullscreenContainer);
document.documentElement.requestFullscreen();
this.mousetrap.bind("esc", this.toggleFullscreen);
} else {
this.fullscreenContainer.remove();
document.exitFullscreen();
this.mousetrap.unbind("esc");
}
this.setState({
isFullscreen: isEnabled
});
};
this.mousetrap.reset();
this.mousetrap.bind(CONFIG.visual["fullscreen-key"], this.toggleFullscreen);
window.addEventListener("fad-request", lyricContainerUpdate);
}
componentWillUnmount() {
Utils.removeQueueListener(this.onQueueChange);
this.configButton.deregister();
this.mousetrap.reset();
window.removeEventListener("fad-request", lyricContainerUpdate);
}
updateVisualOnConfigChange() {
this.availableModes = CONFIG.modes.filter((_, id) => {
return Object.values(CONFIG.providers).some(p => p.on && p.modes.includes(id));
});
if (!CONFIG.visual.colorful) {
this.styleVariables = {
"--lyrics-color-active": CONFIG.visual["active-color"],
"--lyrics-color-inactive": CONFIG.visual["inactive-color"],
"--lyrics-color-background": CONFIG.visual["background-color"],
"--lyrics-highlight-background": CONFIG.visual["highlight-color"],
"--lyrics-background-noise": CONFIG.visual.noise ? "var(--background-noise)" : "unset"
};
}
this.styleVariables = {
...this.styleVariables,
"--lyrics-align-text": CONFIG.visual.alignment,
"--lyrics-font-size": `${CONFIG.visual["font-size"]}px`,
"--animation-tempo": this.state.tempo
};
this.mousetrap.reset();
this.mousetrap.bind(CONFIG.visual["fullscreen-key"], this.toggleFullscreen);
}
componentDidUpdate() {
// Apparently if any of these values are changed, the cached translation will not be updated, hence the need to retranslate
if (
this.translationProvider !== CONFIG.visual["translate:translated-lyrics-source"] ||
this.languageOverride !== CONFIG.visual["translate:detect-language-override"] ||
this.translate !== CONFIG.visual.translate
) {
this.translationProvider = CONFIG.visual["translate:translated-lyrics-source"];
this.languageOverride = CONFIG.visual["translate:detect-language-override"];
this.translate = CONFIG.visual.translate;
this.translateLyrics(false);
return;
}
const language = this.provideLanguageCode(this.state.currentLyrics);
let isTranslated = false;
switch (language) {
case "zh-hans":
case "zh-hant": {
isTranslated = !!(this.state.cn || this.state.hk || this.state.tw);
break;
}
case "ja": {
isTranslated = !!(this.state.romaji || this.state.furigana || this.state.hiragana || this.state.katakana);
break;
}
case "ko": {
isTranslated = !!(this.state.hangul || this.state.romaja);
break;
}
}
!isTranslated && this.translateLyrics();
}
render() {
const fadLyricsContainer = document.getElementById("fad-lyrics-plus-container");
this.state.isFADMode = !!fadLyricsContainer;
if (this.state.isFADMode) {
// Text colors will be set by FAD extension
this.styleVariables = {};
} else if (CONFIG.visual.colorful) {
this.styleVariables = {
"--lyrics-color-active": "white",
"--lyrics-color-inactive": this.state.colors.inactive,
"--lyrics-color-background": this.state.colors.background || "transparent",
"--lyrics-highlight-background": this.state.colors.inactive,
"--lyrics-background-noise": CONFIG.visual.noise ? "var(--background-noise)" : "unset"
};
}
this.styleVariables = {
...this.styleVariables,
"--lyrics-align-text": CONFIG.visual.alignment,
"--lyrics-font-size": `${CONFIG.visual["font-size"]}px`,
"--animation-tempo": this.state.tempo
};
let mode = -1;
if (this.state.explicitMode !== -1) {
mode = this.state.explicitMode;
} else if (this.state.lockMode !== -1) {
mode = this.state.lockMode;
} else {
// Auto switch
if (this.state.karaoke) {
mode = KARAOKE;
} else if (this.state.synced) {
mode = SYNCED;
} else if (this.state.unsynced) {
mode = UNSYNCED;
} else if (this.state.genius) {
mode = GENIUS;
}
}
let activeItem;
let showTranslationButton;
let friendlyLanguage;
const hasTranslation = this.state.neteaseTranslation !== null || this.state.musixmatchTranslation !== null;
if (mode !== -1) {
this.lyricsSource(mode);
const language = this.provideLanguageCode(this.state.currentLyrics);
friendlyLanguage = language && new Intl.DisplayNames(["en"], { type: "language" }).of(language.split("-")[0])?.toLowerCase();
showTranslationButton = (friendlyLanguage || hasTranslation) && (mode === SYNCED || mode === UNSYNCED);
const translatedLyrics = this.state[CONFIG.visual[`translation-mode:${friendlyLanguage}`]];
if (mode === KARAOKE && this.state.karaoke) {
activeItem = react.createElement(CONFIG.visual["synced-compact"] ? SyncedLyricsPage : SyncedExpandedLyricsPage, {
isKara: true,
trackUri: this.state.uri,
lyrics: this.state.karaoke,
provider: this.state.provider,
copyright: this.state.copyright
});
} else if (mode === SYNCED && this.state.synced) {
activeItem = react.createElement(CONFIG.visual["synced-compact"] ? SyncedLyricsPage : SyncedExpandedLyricsPage, {
trackUri: this.state.uri,
lyrics: CONFIG.visual.translate && translatedLyrics ? translatedLyrics : this.state.currentLyrics,
provider: this.state.provider,
copyright: this.state.copyright
});
} else if (mode === UNSYNCED && this.state.unsynced) {
activeItem = react.createElement(UnsyncedLyricsPage, {
trackUri: this.state.uri,
lyrics: CONFIG.visual.translate && translatedLyrics ? translatedLyrics : this.state.currentLyrics,
provider: this.state.provider,
copyright: this.state.copyright
});
} else if (mode === GENIUS && this.state.genius) {
activeItem = react.createElement(GeniusPage, {
isSplitted: CONFIG.visual["dual-genius"],
trackUri: this.state.uri,
lyrics: this.state.genius,
provider: this.state.provider,
copyright: this.state.copyright,
versions: this.state.versions,
versionIndex: this.state.versionIndex,
onVersionChange: this.onVersionChange.bind(this),
lyrics2: this.state.genius2,
versionIndex2: this.state.versionIndex2,
onVersionChange2: this.onVersionChange2.bind(this)
});
}
}
if (!activeItem) {
activeItem = react.createElement(
"div",
{
className: "lyrics-lyricsContainer-LyricsUnavailablePage"
},
react.createElement(
"span",
{
className: "lyrics-lyricsContainer-LyricsUnavailableMessage"
},
this.state.isLoading ? LoadingIcon : "(• _ • )"
)
);
}
this.state.mode = mode;
const out = react.createElement(
"div",
{
className: `lyrics-lyricsContainer-LyricsContainer${CONFIG.visual["fade-blur"] ? " blur-enabled" : ""}${
fadLyricsContainer ? " fad-enabled" : ""
}`,
style: this.styleVariables,
ref: el => {
if (!el) return;
el.onmousewheel = this.onFontSizeChange;
}
},
react.createElement("div", {
className: "lyrics-lyricsContainer-LyricsBackground"
}),
react.createElement(
"div",
{
className: "lyrics-config-button-container"
},
showTranslationButton &&
react.createElement(TranslationMenu, {
friendlyLanguage,
hasTranslation: {
musixmatch: this.state.musixmatchTranslation !== null,
netease: this.state.neteaseTranslation !== null
}
}),
react.createElement(AdjustmentsMenu, { mode }),
react.createElement(
Spicetify.ReactComponent.TooltipWrapper,
{
label: this.state.isCached ? "Lyrics cached" : "Cache lyrics"
},
react.createElement(
"button",
{
className: "lyrics-config-button",
onClick: () => {
const { synced, unsynced, karaoke, genius } = this.state;
if (!synced && !unsynced && !karaoke && !genius) {
Spicetify.showNotification("No lyrics to cache", true);
return;
}
this.saveLocalLyrics(this.currentTrackUri, { synced, unsynced, karaoke, genius });
Spicetify.showNotification("Lyrics cached");
}
},
react.createElement("svg", {
width: 16,
height: 16,
viewBox: "0 0 16 16",
fill: "currentColor",
dangerouslySetInnerHTML: {
__html: Spicetify.SVGIcons[this.state.isCached ? "downloaded" : "download"]
}
})
)
),
react.createElement(
Spicetify.ReactComponent.TooltipWrapper,
{
label: "Load lyrics from file"
},
react.createElement(
"button",
{
className: "lyrics-config-button",
onClick: () => {
document.getElementById("lyrics-file-input").click();
}
},
react.createElement("input", {
type: "file",
id: "lyrics-file-input",
accept: ".lrc,.txt",
onChange: this.processLyricsFromFile.bind(this),
style: {
display: "none"
}
}),
react.createElement("svg", {
width: 16,
height: 16,
viewBox: "0 0 16 16",
fill: "currentColor",
dangerouslySetInnerHTML: {
__html: Spicetify.SVGIcons["plus-alt"]
}
})
)
)
),
activeItem,
!!document.querySelector(".main-topBar-topbarContentWrapper") &&
react.createElement(TopBarContent, {
links: this.availableModes,
activeLink: CONFIG.modes[mode],
lockLink: CONFIG.modes[this.state.lockMode],
switchCallback: label => {
const mode = CONFIG.modes.findIndex(a => a === label);
if (mode !== this.state.mode) {
this.setState({ explicitMode: mode });
this.state.provider !== "local" && this.fetchLyrics(Spicetify.Player.data.item, mode);
}
},
lockCallback: label => {
let mode = CONFIG.modes.findIndex(a => a === label);
if (mode === this.state.lockMode) {
mode = -1;
}
this.setState({ explicitMode: mode, lockMode: mode });
this.fetchLyrics(Spicetify.Player.data.item, mode);
CONFIG.locked = mode;
localStorage.setItem("lyrics-plus:lock-mode", mode);
}
})
);
if (this.state.isFullscreen) return reactDOM.createPortal(out, this.fullscreenContainer);
if (fadLyricsContainer) return reactDOM.createPortal(out, fadLyricsContainer);
return out;
}
}