657 lines
14 KiB
JavaScript
657 lines
14 KiB
JavaScript
const ButtonSVG = ({ icon, active = true, onClick }) => {
|
|
return react.createElement(
|
|
"button",
|
|
{
|
|
className: `switch${active ? "" : " disabled"}`,
|
|
onClick
|
|
},
|
|
react.createElement("svg", {
|
|
width: 16,
|
|
height: 16,
|
|
viewBox: "0 0 16 16",
|
|
fill: "currentColor",
|
|
dangerouslySetInnerHTML: {
|
|
__html: icon
|
|
}
|
|
})
|
|
);
|
|
};
|
|
|
|
const SwapButton = ({ icon, disabled, onClick }) => {
|
|
return react.createElement(
|
|
"button",
|
|
{
|
|
className: "switch small",
|
|
onClick,
|
|
disabled
|
|
},
|
|
react.createElement("svg", {
|
|
width: 10,
|
|
height: 10,
|
|
viewBox: "0 0 16 16",
|
|
fill: "currentColor",
|
|
dangerouslySetInnerHTML: {
|
|
__html: icon
|
|
}
|
|
})
|
|
);
|
|
};
|
|
|
|
const CacheButton = () => {
|
|
let lyrics = {};
|
|
|
|
try {
|
|
const localLyrics = JSON.parse(localStorage.getItem("lyrics-plus:local-lyrics"));
|
|
if (!localLyrics || typeof localLyrics !== "object") {
|
|
throw "";
|
|
}
|
|
lyrics = localLyrics;
|
|
} catch {
|
|
lyrics = {};
|
|
}
|
|
|
|
const [count, setCount] = useState(Object.keys(lyrics).length);
|
|
const text = count ? "Clear cached lyrics" : "No cached lyrics";
|
|
|
|
return react.createElement(
|
|
"button",
|
|
{
|
|
className: "btn",
|
|
onClick: () => {
|
|
localStorage.removeItem("lyrics-plus:local-lyrics");
|
|
setCount(0);
|
|
},
|
|
disabled: !count
|
|
},
|
|
text
|
|
);
|
|
};
|
|
|
|
const RefreshTokenButton = ({ setTokenCallback }) => {
|
|
const [buttonText, setButtonText] = useState("Refresh token");
|
|
|
|
useEffect(() => {
|
|
if (buttonText === "Refreshing token...") {
|
|
Spicetify.CosmosAsync.get("https://apic-desktop.musixmatch.com/ws/1.1/token.get?app_id=web-desktop-app-v1.0", null, {
|
|
authority: "apic-desktop.musixmatch.com"
|
|
})
|
|
.then(({ message: response }) => {
|
|
if (response.header.status_code === 200 && response.body.user_token) {
|
|
setTokenCallback(response.body.user_token);
|
|
setButtonText("Token refreshed");
|
|
} else if (response.header.status_code === 401) {
|
|
setButtonText("Too many attempts");
|
|
} else {
|
|
setButtonText("Failed to refresh token");
|
|
console.error("Failed to refresh token", response);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
setButtonText("Failed to refresh token");
|
|
console.error("Failed to refresh token", error);
|
|
});
|
|
}
|
|
}, [buttonText]);
|
|
|
|
return react.createElement(
|
|
"button",
|
|
{
|
|
className: "btn",
|
|
onClick: () => {
|
|
setButtonText("Refreshing token...");
|
|
},
|
|
disabled: buttonText !== "Refresh token"
|
|
},
|
|
buttonText
|
|
);
|
|
};
|
|
|
|
const ConfigSlider = ({ name, defaultValue, onChange = () => {} }) => {
|
|
const [active, setActive] = useState(defaultValue);
|
|
|
|
const toggleState = useCallback(() => {
|
|
const state = !active;
|
|
setActive(state);
|
|
onChange(state);
|
|
}, [active]);
|
|
|
|
return react.createElement(
|
|
"div",
|
|
{
|
|
className: "setting-row"
|
|
},
|
|
react.createElement(
|
|
"label",
|
|
{
|
|
className: "col description"
|
|
},
|
|
name
|
|
),
|
|
react.createElement(
|
|
"div",
|
|
{
|
|
className: "col action"
|
|
},
|
|
react.createElement(ButtonSVG, {
|
|
icon: Spicetify.SVGIcons.check,
|
|
active,
|
|
onClick: toggleState
|
|
})
|
|
)
|
|
);
|
|
};
|
|
|
|
const ConfigSelection = ({ name, defaultValue, options, onChange = () => {} }) => {
|
|
const [value, setValue] = useState(defaultValue);
|
|
|
|
const setValueCallback = useCallback(
|
|
event => {
|
|
let value = event.target.value;
|
|
if (!Number.isNaN(Number(value))) {
|
|
value = Number.parseInt(value);
|
|
}
|
|
setValue(value);
|
|
onChange(value);
|
|
},
|
|
[value, options]
|
|
);
|
|
|
|
useEffect(() => {
|
|
setValue(defaultValue);
|
|
}, [defaultValue]);
|
|
|
|
if (!Object.keys(options).length) return null;
|
|
|
|
return react.createElement(
|
|
"div",
|
|
{
|
|
className: "setting-row"
|
|
},
|
|
react.createElement(
|
|
"label",
|
|
{
|
|
className: "col description"
|
|
},
|
|
name
|
|
),
|
|
react.createElement(
|
|
"div",
|
|
{
|
|
className: "col action"
|
|
},
|
|
react.createElement(
|
|
"select",
|
|
{
|
|
className: "main-dropDown-dropDown",
|
|
value,
|
|
onChange: setValueCallback
|
|
},
|
|
Object.keys(options).map(item =>
|
|
react.createElement(
|
|
"option",
|
|
{
|
|
value: item
|
|
},
|
|
options[item]
|
|
)
|
|
)
|
|
)
|
|
)
|
|
);
|
|
};
|
|
|
|
const ConfigInput = ({ name, defaultValue, onChange = () => {} }) => {
|
|
const [value, setValue] = useState(defaultValue);
|
|
|
|
const setValueCallback = useCallback(
|
|
event => {
|
|
const value = event.target.value;
|
|
setValue(value);
|
|
onChange(value);
|
|
},
|
|
[value]
|
|
);
|
|
|
|
return react.createElement(
|
|
"div",
|
|
{
|
|
className: "setting-row"
|
|
},
|
|
react.createElement(
|
|
"label",
|
|
{
|
|
className: "col description"
|
|
},
|
|
name
|
|
),
|
|
react.createElement(
|
|
"div",
|
|
{
|
|
className: "col action"
|
|
},
|
|
react.createElement("input", {
|
|
value,
|
|
onChange: setValueCallback
|
|
})
|
|
)
|
|
);
|
|
};
|
|
|
|
const ConfigAdjust = ({ name, defaultValue, step, min, max, onChange = () => {} }) => {
|
|
const [value, setValue] = useState(defaultValue);
|
|
|
|
function adjust(dir) {
|
|
let temp = value + dir * step;
|
|
if (temp < min) {
|
|
temp = min;
|
|
} else if (temp > max) {
|
|
temp = max;
|
|
}
|
|
setValue(temp);
|
|
onChange(temp);
|
|
}
|
|
return react.createElement(
|
|
"div",
|
|
{
|
|
className: "setting-row"
|
|
},
|
|
react.createElement(
|
|
"label",
|
|
{
|
|
className: "col description"
|
|
},
|
|
name
|
|
),
|
|
react.createElement(
|
|
"div",
|
|
{
|
|
className: "col action"
|
|
},
|
|
react.createElement(SwapButton, {
|
|
icon: `<path d="M2 7h12v2H0z"/>`,
|
|
onClick: () => adjust(-1),
|
|
disabled: value === min
|
|
}),
|
|
react.createElement(
|
|
"p",
|
|
{
|
|
className: "adjust-value"
|
|
},
|
|
value
|
|
),
|
|
react.createElement(SwapButton, {
|
|
icon: Spicetify.SVGIcons.plus2px,
|
|
onClick: () => adjust(1),
|
|
disabled: value === max
|
|
})
|
|
)
|
|
);
|
|
};
|
|
|
|
const ConfigHotkey = ({ name, defaultValue, onChange = () => {} }) => {
|
|
const [value, setValue] = useState(defaultValue);
|
|
const [trap] = useState(new Spicetify.Mousetrap());
|
|
|
|
function record() {
|
|
trap.handleKey = (character, modifiers, e) => {
|
|
if (e.type === "keydown") {
|
|
const sequence = [...new Set([...modifiers, character])];
|
|
if (sequence.length === 1 && sequence[0] === "esc") {
|
|
onChange("");
|
|
setValue("");
|
|
return;
|
|
}
|
|
setValue(sequence.join("+"));
|
|
}
|
|
};
|
|
}
|
|
|
|
function finishRecord() {
|
|
trap.handleKey = () => {};
|
|
onChange(value);
|
|
}
|
|
|
|
return react.createElement(
|
|
"div",
|
|
{
|
|
className: "setting-row"
|
|
},
|
|
react.createElement(
|
|
"label",
|
|
{
|
|
className: "col description"
|
|
},
|
|
name
|
|
),
|
|
react.createElement(
|
|
"div",
|
|
{
|
|
className: "col action"
|
|
},
|
|
react.createElement("input", {
|
|
value,
|
|
onFocus: record,
|
|
onBlur: finishRecord
|
|
})
|
|
)
|
|
);
|
|
};
|
|
|
|
const ServiceAction = ({ item, setTokenCallback }) => {
|
|
switch (item.name) {
|
|
case "local":
|
|
return react.createElement(CacheButton);
|
|
case "musixmatch":
|
|
return react.createElement(RefreshTokenButton, { setTokenCallback });
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const ServiceOption = ({ item, onToggle, onSwap, isFirst = false, isLast = false, onTokenChange = null }) => {
|
|
const [token, setToken] = useState(item.token);
|
|
const [active, setActive] = useState(item.on);
|
|
|
|
const setTokenCallback = useCallback(
|
|
token => {
|
|
setToken(token);
|
|
onTokenChange(item.name, token);
|
|
},
|
|
[item.token]
|
|
);
|
|
|
|
const toggleActive = useCallback(() => {
|
|
if (item.name === "genius" && spotifyVersion >= "1.2.31") return;
|
|
const state = !active;
|
|
setActive(state);
|
|
onToggle(item.name, state);
|
|
}, [active]);
|
|
|
|
return react.createElement(
|
|
"div",
|
|
null,
|
|
react.createElement(
|
|
"div",
|
|
{
|
|
className: "setting-row"
|
|
},
|
|
react.createElement(
|
|
"h3",
|
|
{
|
|
className: "col description"
|
|
},
|
|
item.name
|
|
),
|
|
react.createElement(
|
|
"div",
|
|
{
|
|
className: "col action"
|
|
},
|
|
react.createElement(ServiceAction, {
|
|
item,
|
|
setTokenCallback
|
|
}),
|
|
react.createElement(SwapButton, {
|
|
icon: Spicetify.SVGIcons["chart-up"],
|
|
onClick: () => onSwap(item.name, -1),
|
|
disabled: isFirst
|
|
}),
|
|
react.createElement(SwapButton, {
|
|
icon: Spicetify.SVGIcons["chart-down"],
|
|
onClick: () => onSwap(item.name, 1),
|
|
disabled: isLast
|
|
}),
|
|
react.createElement(ButtonSVG, {
|
|
icon: Spicetify.SVGIcons.check,
|
|
active,
|
|
onClick: toggleActive
|
|
})
|
|
)
|
|
),
|
|
react.createElement("span", {
|
|
dangerouslySetInnerHTML: {
|
|
__html: item.desc
|
|
}
|
|
}),
|
|
item.token !== undefined &&
|
|
react.createElement("input", {
|
|
placeholder: `Place your ${item.name} token here`,
|
|
value: token,
|
|
onChange: event => setTokenCallback(event.target.value)
|
|
})
|
|
);
|
|
};
|
|
|
|
const ServiceList = ({ itemsList, onListChange = () => {}, onToggle = () => {}, onTokenChange = () => {} }) => {
|
|
const [items, setItems] = useState(itemsList);
|
|
const maxIndex = items.length - 1;
|
|
|
|
const onSwap = useCallback(
|
|
(name, direction) => {
|
|
const curPos = items.findIndex(val => val === name);
|
|
const newPos = curPos + direction;
|
|
[items[curPos], items[newPos]] = [items[newPos], items[curPos]];
|
|
onListChange(items);
|
|
setItems([...items]);
|
|
},
|
|
[items]
|
|
);
|
|
|
|
return items.map((key, index) => {
|
|
const item = CONFIG.providers[key];
|
|
item.name = key;
|
|
return react.createElement(ServiceOption, {
|
|
item,
|
|
key,
|
|
isFirst: index === 0,
|
|
isLast: index === maxIndex,
|
|
onSwap,
|
|
onTokenChange,
|
|
onToggle
|
|
});
|
|
});
|
|
};
|
|
|
|
const OptionList = ({ type, items, onChange }) => {
|
|
const [itemList, setItemList] = useState(items);
|
|
const [, forceUpdate] = useState();
|
|
|
|
useEffect(() => {
|
|
if (!type) return;
|
|
|
|
const eventListener = event => {
|
|
if (event.detail?.type !== type) return;
|
|
setItemList(event.detail.items);
|
|
};
|
|
document.addEventListener("lyrics-plus", eventListener);
|
|
|
|
return () => document.removeEventListener("lyrics-plus", eventListener);
|
|
}, []);
|
|
|
|
return itemList.map(item => {
|
|
if (!item || (item.when && !item.when())) {
|
|
return;
|
|
}
|
|
|
|
const onChangeItem = item.onChange || onChange;
|
|
|
|
return react.createElement(
|
|
"div",
|
|
null,
|
|
react.createElement(item.type, {
|
|
...item,
|
|
name: item.desc,
|
|
defaultValue: CONFIG.visual[item.key],
|
|
onChange: value => {
|
|
onChangeItem(item.key, value);
|
|
forceUpdate({});
|
|
}
|
|
}),
|
|
item.info &&
|
|
react.createElement("span", {
|
|
dangerouslySetInnerHTML: {
|
|
__html: item.info
|
|
}
|
|
})
|
|
);
|
|
});
|
|
};
|
|
|
|
function openConfig() {
|
|
const configContainer = react.createElement(
|
|
"div",
|
|
{
|
|
id: `${APP_NAME}-config-container`
|
|
},
|
|
react.createElement("h2", null, "Options"),
|
|
react.createElement(OptionList, {
|
|
items: [
|
|
{
|
|
desc: "Playbar button",
|
|
key: "playbar-button",
|
|
info: "Replace Spotify's lyrics button with Lyrics Plus.",
|
|
type: ConfigSlider
|
|
},
|
|
{
|
|
desc: "Global delay",
|
|
info: "Offset (in ms) across all tracks.",
|
|
key: "global-delay",
|
|
type: ConfigAdjust,
|
|
min: -10000,
|
|
max: 10000,
|
|
step: 250
|
|
},
|
|
{
|
|
desc: "Font size",
|
|
info: "(or Ctrl + Mouse scroll in main app)",
|
|
key: "font-size",
|
|
type: ConfigAdjust,
|
|
min: fontSizeLimit.min,
|
|
max: fontSizeLimit.max,
|
|
step: fontSizeLimit.step
|
|
},
|
|
{
|
|
desc: "Alignment",
|
|
key: "alignment",
|
|
type: ConfigSelection,
|
|
options: {
|
|
left: "Left",
|
|
center: "Center",
|
|
right: "Right"
|
|
}
|
|
},
|
|
{
|
|
desc: "Fullscreen hotkey",
|
|
key: "fullscreen-key",
|
|
type: ConfigHotkey
|
|
},
|
|
{
|
|
desc: "Compact synced: Lines to show before",
|
|
key: "lines-before",
|
|
type: ConfigSelection,
|
|
options: [0, 1, 2, 3, 4]
|
|
},
|
|
{
|
|
desc: "Compact synced: Lines to show after",
|
|
key: "lines-after",
|
|
type: ConfigSelection,
|
|
options: [0, 1, 2, 3, 4]
|
|
},
|
|
{
|
|
desc: "Compact synced: Fade-out blur",
|
|
key: "fade-blur",
|
|
type: ConfigSlider
|
|
},
|
|
{
|
|
desc: "Noise overlay",
|
|
key: "noise",
|
|
type: ConfigSlider
|
|
},
|
|
{
|
|
desc: "Colorful background",
|
|
key: "colorful",
|
|
type: ConfigSlider
|
|
},
|
|
{
|
|
desc: "Background color",
|
|
key: "background-color",
|
|
type: ConfigInput,
|
|
when: () => !CONFIG.visual.colorful
|
|
},
|
|
{
|
|
desc: "Active text color",
|
|
key: "active-color",
|
|
type: ConfigInput,
|
|
when: () => !CONFIG.visual.colorful
|
|
},
|
|
{
|
|
desc: "Inactive text color",
|
|
key: "inactive-color",
|
|
type: ConfigInput,
|
|
when: () => !CONFIG.visual.colorful
|
|
},
|
|
{
|
|
desc: "Highlight text background",
|
|
key: "highlight-color",
|
|
type: ConfigInput,
|
|
when: () => !CONFIG.visual.colorful
|
|
},
|
|
{
|
|
desc: "Text convertion: Japanese Detection threshold (Advanced)",
|
|
info: "Checks if whenever Kana is dominant in lyrics. If the result passes the threshold, it's most likely Japanese, and vice versa. This setting is in percentage.",
|
|
key: "ja-detect-threshold",
|
|
type: ConfigAdjust,
|
|
min: thresholdSizeLimit.min,
|
|
max: thresholdSizeLimit.max,
|
|
step: thresholdSizeLimit.step
|
|
},
|
|
{
|
|
desc: "Text convertion: Traditional-Simplified Detection threshold (Advanced)",
|
|
info: "Checks if whenever Traditional or Simplified is dominant in lyrics. If the result passes the threshold, it's most likely Simplified, and vice versa. This setting is in percentage.",
|
|
key: "hans-detect-threshold",
|
|
type: ConfigAdjust,
|
|
min: thresholdSizeLimit.min,
|
|
max: thresholdSizeLimit.max,
|
|
step: thresholdSizeLimit.step
|
|
}
|
|
],
|
|
onChange: (name, value) => {
|
|
CONFIG.visual[name] = value;
|
|
localStorage.setItem(`${APP_NAME}:visual:${name}`, value);
|
|
lyricContainerUpdate?.();
|
|
|
|
const configChange = new CustomEvent("lyrics-plus", {
|
|
detail: {
|
|
type: "config",
|
|
name: name,
|
|
value: value
|
|
}
|
|
});
|
|
window.dispatchEvent(configChange);
|
|
}
|
|
}),
|
|
react.createElement("h2", null, "Providers"),
|
|
react.createElement(ServiceList, {
|
|
itemsList: CONFIG.providersOrder,
|
|
onListChange: list => {
|
|
CONFIG.providersOrder = list;
|
|
localStorage.setItem(`${APP_NAME}:services-order`, JSON.stringify(list));
|
|
},
|
|
onToggle: (name, value) => {
|
|
CONFIG.providers[name].on = value;
|
|
localStorage.setItem(`${APP_NAME}:provider:${name}:on`, value);
|
|
lyricContainerUpdate?.();
|
|
},
|
|
onTokenChange: (name, value) => {
|
|
CONFIG.providers[name].token = value;
|
|
localStorage.setItem(`${APP_NAME}:provider:${name}:token`, value);
|
|
}
|
|
})
|
|
);
|
|
|
|
Spicetify.PopupModal.display({
|
|
title: "Lyrics Plus",
|
|
content: configContainer,
|
|
isLarge: true
|
|
});
|
|
}
|