feat(spicetify): add lyrics-plus custom app

This commit is contained in:
Sergio Laín 2024-06-26 12:05:08 +02:00
parent 4d9978ef7f
commit 50aeec9c41
No known key found for this signature in database
GPG key ID: 8429B2EE312F8150
22 changed files with 5020 additions and 2 deletions

View file

@ -0,0 +1,326 @@
const OptionsMenuItemIcon = react.createElement(
"svg",
{
width: 16,
height: 16,
viewBox: "0 0 16 16",
fill: "currentColor"
},
react.createElement("path", {
d: "M13.985 2.383L5.127 12.754 1.388 8.375l-.658.77 4.397 5.149 9.618-11.262z"
})
);
const OptionsMenuItem = react.memo(({ onSelect, value, isSelected }) => {
return react.createElement(
Spicetify.ReactComponent.MenuItem,
{
onClick: onSelect,
icon: isSelected ? OptionsMenuItemIcon : null,
trailingIcon: isSelected ? OptionsMenuItemIcon : null
},
value
);
});
const OptionsMenu = react.memo(({ options, onSelect, selected, defaultValue, bold = false }) => {
/**
* <Spicetify.ReactComponent.ContextMenu
* menu = { options.map(a => <OptionsMenuItem>) }
* >
* <button>
* <span> {select.value} </span>
* <svg> arrow icon </svg>
* </button>
* </Spicetify.ReactComponent.ContextMenu>
*/
const menuRef = react.useRef(null);
return react.createElement(
Spicetify.ReactComponent.ContextMenu,
{
menu: react.createElement(
Spicetify.ReactComponent.Menu,
{},
options.map(({ key, value }) =>
react.createElement(OptionsMenuItem, {
value,
onSelect: () => {
onSelect(key);
// Close menu on item click
menuRef.current?.click();
},
isSelected: selected?.key === key
})
)
),
trigger: "click",
action: "toggle",
renderInline: false
},
react.createElement(
"button",
{
className: "optionsMenu-dropBox",
ref: menuRef
},
react.createElement(
"span",
{
className: bold ? "main-type-mestoBold" : "main-type-mesto"
},
selected?.value || defaultValue
),
react.createElement(
"svg",
{
height: "16",
width: "16",
fill: "currentColor",
viewBox: "0 0 16 16"
},
react.createElement("path", {
d: "M3 6l5 5.794L13 6z"
})
)
)
);
});
const TranslationMenu = react.memo(({ friendlyLanguage, hasTranslation }) => {
const items = useMemo(() => {
let sourceOptions = {
none: "None"
};
const languageOptions = {
off: "Off",
"zh-hans": "Chinese (Simplified)",
"zh-hant": "Chinese (Traditional)",
ja: "Japanese",
ko: "Korean"
};
let modeOptions = {};
if (hasTranslation.musixmatch) {
sourceOptions = {
...sourceOptions,
musixmatchTranslation: "English (Musixmatch)"
};
}
if (hasTranslation.netease) {
sourceOptions = {
...sourceOptions,
neteaseTranslation: "Chinese (Netease)"
};
}
switch (friendlyLanguage) {
case "japanese": {
modeOptions = {
furigana: "Furigana",
romaji: "Romaji",
hiragana: "Hiragana",
katakana: "Katakana"
};
break;
}
case "korean": {
modeOptions = {
hangul: "Hangul",
romaja: "Romaja"
};
break;
}
case "chinese": {
modeOptions = {
cn: "Simplified Chinese",
hk: "Traditional Chinese (Hong Kong)",
tw: "Traditional Chinese (Taiwan)"
};
break;
}
}
return [
{
desc: "Translation Provider",
key: "translate:translated-lyrics-source",
type: ConfigSelection,
options: sourceOptions,
renderInline: true
},
{
desc: "Language Override",
key: "translate:detect-language-override",
type: ConfigSelection,
options: languageOptions,
renderInline: true
},
{
desc: "Display Mode",
key: `translation-mode:${friendlyLanguage}`,
type: ConfigSelection,
options: modeOptions,
renderInline: true
},
{
desc: "Convert",
key: "translate",
type: ConfigSlider,
trigger: "click",
action: "toggle",
renderInline: true
}
];
}, [friendlyLanguage]);
useEffect(() => {
// Currently opened Context Menu does not receive prop changes
// If we were to use keys the Context Menu would close on re-render
const event = new CustomEvent("lyrics-plus", {
detail: {
type: "translation-menu",
items
}
});
document.dispatchEvent(event);
}, [friendlyLanguage]);
return react.createElement(
Spicetify.ReactComponent.TooltipWrapper,
{
label: "Conversion"
},
react.createElement(
"div",
{
className: "lyrics-tooltip-wrapper"
},
react.createElement(
Spicetify.ReactComponent.ContextMenu,
{
menu: react.createElement(
Spicetify.ReactComponent.Menu,
{},
react.createElement("h3", null, " Conversions"),
react.createElement(OptionList, {
type: "translation-menu",
items,
onChange: (name, value) => {
CONFIG.visual[name] = value;
localStorage.setItem(`${APP_NAME}:visual:${name}`, value);
lyricContainerUpdate?.();
}
})
),
trigger: "click",
action: "toggle",
renderInline: true
},
react.createElement(
"button",
{
className: "lyrics-config-button"
},
react.createElement(
"p1",
{
width: 16,
height: 16,
viewBox: "0 0 16 10.3",
fill: "currentColor"
},
"⇄"
)
)
)
)
);
});
const AdjustmentsMenu = react.memo(({ mode }) => {
return react.createElement(
Spicetify.ReactComponent.TooltipWrapper,
{
label: "Adjustments"
},
react.createElement(
"div",
{
className: "lyrics-tooltip-wrapper"
},
react.createElement(
Spicetify.ReactComponent.ContextMenu,
{
menu: react.createElement(
Spicetify.ReactComponent.Menu,
{},
react.createElement("h3", null, " Adjustments"),
react.createElement(OptionList, {
items: [
{
desc: "Font size",
key: "font-size",
type: ConfigAdjust,
min: fontSizeLimit.min,
max: fontSizeLimit.max,
step: fontSizeLimit.step
},
{
desc: "Track delay",
key: "delay",
type: ConfigAdjust,
min: Number.NEGATIVE_INFINITY,
max: Number.POSITIVE_INFINITY,
step: 250,
when: () => mode === SYNCED || mode === KARAOKE
},
{
desc: "Compact",
key: "synced-compact",
type: ConfigSlider,
when: () => mode === SYNCED || mode === KARAOKE
},
{
desc: "Dual panel",
key: "dual-genius",
type: ConfigSlider,
when: () => mode === GENIUS
}
],
onChange: (name, value) => {
CONFIG.visual[name] = value;
localStorage.setItem(`${APP_NAME}:visual:${name}`, value);
name === "delay" && localStorage.setItem(`lyrics-delay:${Spicetify.Player.data.item.uri}`, value);
lyricContainerUpdate?.();
}
})
),
trigger: "click",
action: "toggle",
renderInline: true
},
react.createElement(
"button",
{
className: "lyrics-config-button"
},
react.createElement(
"svg",
{
width: 16,
height: 16,
viewBox: "0 0 16 10.3",
fill: "currentColor"
},
react.createElement("path", {
d: "M 10.8125,0 C 9.7756347,0 8.8094481,0.30798341 8,0.836792 7.1905519,0.30798341 6.2243653,0 5.1875,0 2.3439941,0 0,2.3081055 0,5.15625 0,8.0001222 2.3393555,10.3125 5.1875,10.3125 6.2243653,10.3125 7.1905519,10.004517 8,9.4757081 8.8094481,10.004517 9.7756347,10.3125 10.8125,10.3125 13.656006,10.3125 16,8.0043944 16,5.15625 16,2.3123779 13.660644,0 10.8125,0 Z M 8,2.0146484 C 8.2629394,2.2503662 8.4963378,2.5183106 8.6936034,2.8125 H 7.3063966 C 7.5036622,2.5183106 7.7370606,2.2503662 8,2.0146484 Z M 6.619995,4.6875 C 6.6560059,4.3625487 6.7292481,4.0485841 6.8350831,3.75 h 2.3298338 c 0.1059572,0.2985841 0.1790772,0.6125487 0.21521,0.9375 z M 9.380005,5.625 C 9.3439941,5.9499512 9.2707519,6.2639159 9.1649169,6.5625 H 6.8350831 C 6.7291259,6.2639159 6.6560059,5.9499512 6.6198731,5.625 Z M 5.1875,9.375 c -2.3435059,0 -4.25,-1.8925781 -4.25,-4.21875 0,-2.3261719 1.9064941,-4.21875 4.25,-4.21875 0.7366944,0 1.4296875,0.1899414 2.0330809,0.5233154 C 6.2563478,2.3981934 5.65625,3.7083741 5.65625,5.15625 c 0,1.4478759 0.6000978,2.7580566 1.5643309,3.6954347 C 6.6171875,9.1850584 5.9241944,9.375 5.1875,9.375 Z M 8,8.2978516 C 7.7370606,8.0621337 7.5036622,7.7938231 7.3063966,7.4996337 H 8.6936034 C 8.4963378,7.7938231 8.2629394,8.0621338 8,8.2978516 Z M 10.8125,9.375 C 10.075806,9.375 9.3828125,9.1850584 8.7794191,8.8516847 9.7436522,7.9143066 10.34375,6.6041259 10.34375,5.15625 10.34375,3.7083741 9.7436522,2.3981934 8.7794191,1.4608154 9.3828125,1.1274414 10.075806,0.9375 10.8125,0.9375 c 2.343506,0 4.25,1.8925781 4.25,4.21875 0,2.3261719 -1.906494,4.21875 -4.25,4.21875 z m 0,0"
})
)
)
)
)
);
});

View file

@ -0,0 +1,739 @@
const CreditFooter = react.memo(({ provider, copyright }) => {
if (provider === "local") return null;
const credit = [Spicetify.Locale.get("web-player.lyrics.providedBy", provider)];
if (copyright) {
credit.push(...copyright.split("\n"));
}
return (
provider &&
react.createElement(
"p",
{
className: "lyrics-lyricsContainer-Provider main-type-mesto",
dir: "auto"
},
credit.join(" • ")
)
);
});
const IdlingIndicator = ({ isActive, progress, delay }) => {
return react.createElement(
"div",
{
className: `lyrics-idling-indicator ${
!isActive ? "lyrics-idling-indicator-hidden" : ""
} lyrics-lyricsContainer-LyricsLine lyrics-lyricsContainer-LyricsLine-active`,
style: {
"--position-index": 0,
"--animation-index": 1,
"--indicator-delay": `${delay}ms`
}
},
react.createElement("div", { className: `lyrics-idling-indicator__circle ${progress >= 0.05 ? "active" : ""}` }),
react.createElement("div", { className: `lyrics-idling-indicator__circle ${progress >= 0.33 ? "active" : ""}` }),
react.createElement("div", { className: `lyrics-idling-indicator__circle ${progress >= 0.66 ? "active" : ""}` })
);
};
const emptyLine = {
startTime: 0,
endTime: 0,
text: []
};
const useTrackPosition = callback => {
const callbackRef = useRef();
callbackRef.current = callback;
useEffect(() => {
const interval = setInterval(callbackRef.current, 50);
return () => {
clearInterval(interval);
};
}, [callbackRef]);
};
const KaraokeLine = ({ text, isActive, position, startTime }) => {
if (!isActive) {
return text.map(({ word }) => word).join("");
}
return text.map(({ word, time }) => {
const isWordActive = position >= startTime;
startTime += time;
return react.createElement(
"span",
{
className: `lyrics-lyricsContainer-Karaoke-Word${isWordActive ? " lyrics-lyricsContainer-Karaoke-WordActive" : ""}`,
style: {
"--word-duration": `${time}ms`
}
},
word
);
});
};
const SyncedLyricsPage = react.memo(({ lyrics = [], provider, copyright, isKara }) => {
const [position, setPosition] = useState(0);
const activeLineEle = useRef();
const lyricContainerEle = useRef();
useTrackPosition(() => {
const newPos = Spicetify.Player.getProgress();
const delay = CONFIG.visual["global-delay"] + CONFIG.visual.delay;
if (newPos !== position) {
setPosition(newPos + delay);
}
});
const lyricWithEmptyLines = useMemo(
() =>
[emptyLine, emptyLine, ...lyrics].map((line, i) => ({
...line,
lineNumber: i
})),
[lyrics]
);
const lyricsId = lyrics[0].text;
let activeLineIndex = 0;
for (let i = lyricWithEmptyLines.length - 1; i > 0; i--) {
if (position >= lyricWithEmptyLines[i].startTime) {
activeLineIndex = i;
break;
}
}
const activeLines = useMemo(() => {
const startIndex = Math.max(activeLineIndex - 1 - CONFIG.visual["lines-before"], 0);
// 3 lines = 1 padding top + 1 padding bottom + 1 active
const linesCount = CONFIG.visual["lines-before"] + CONFIG.visual["lines-after"] + 3;
return lyricWithEmptyLines.slice(startIndex, startIndex + linesCount);
}, [activeLineIndex, lyricWithEmptyLines]);
let offset = lyricContainerEle.current ? lyricContainerEle.current.clientHeight / 2 : 0;
if (activeLineEle.current) {
offset += -(activeLineEle.current.offsetTop + activeLineEle.current.clientHeight / 2);
}
const rawLyrics = Utils.convertParsedToLRC(lyrics);
return react.createElement(
"div",
{
className: "lyrics-lyricsContainer-SyncedLyricsPage",
ref: lyricContainerEle
},
react.createElement(
"div",
{
className: "lyrics-lyricsContainer-SyncedLyrics",
style: {
"--offset": `${offset}px`
},
key: lyricsId
},
activeLines.map(({ text, lineNumber, startTime }, i) => {
if (i === 1 && activeLineIndex === 1) {
return react.createElement(IdlingIndicator, {
progress: position / activeLines[2].startTime,
delay: activeLines[2].startTime / 3
});
}
let className = "lyrics-lyricsContainer-LyricsLine";
const activeElementIndex = Math.min(activeLineIndex, CONFIG.visual["lines-before"] + 1);
let ref;
const isActive = activeElementIndex === i;
if (isActive) {
className += " lyrics-lyricsContainer-LyricsLine-active";
ref = activeLineEle;
}
let animationIndex;
if (activeLineIndex <= CONFIG.visual["lines-before"]) {
animationIndex = i - activeLineIndex;
} else {
animationIndex = i - CONFIG.visual["lines-before"] - 1;
}
const paddingLine = (animationIndex < 0 && -animationIndex > CONFIG.visual["lines-before"]) || animationIndex > CONFIG.visual["lines-after"];
if (paddingLine) {
className += " lyrics-lyricsContainer-LyricsLine-paddingLine";
}
return react.createElement(
"p",
{
className,
style: {
cursor: "pointer",
"--position-index": animationIndex,
"--animation-index": (animationIndex < 0 ? 0 : animationIndex) + 1,
"--blur-index": Math.abs(animationIndex)
},
key: lineNumber,
dir: "auto",
ref,
onClick: event => {
if (startTime) {
Spicetify.Player.seek(startTime);
}
},
onContextMenu: event => {
event.preventDefault();
Spicetify.Platform.ClipboardAPI.copy(rawLyrics)
.then(() => Spicetify.showNotification("Lyrics copied to clipboard"))
.catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard"));
}
},
!isKara ? text : react.createElement(KaraokeLine, { text, startTime, position, isActive })
);
})
),
react.createElement(CreditFooter, {
provider,
copyright
})
);
});
class SearchBar extends react.Component {
constructor() {
super();
this.state = {
hidden: true,
atNode: 0,
foundNodes: []
};
this.container = null;
}
componentDidMount() {
this.viewPort = document.querySelector(".main-view-container .os-viewport");
this.mainViewOffsetTop = document.querySelector(".Root__main-view").offsetTop;
this.toggleCallback = () => {
if (!(Spicetify.Platform.History.location.pathname === "/lyrics-plus" && this.container)) return;
if (this.state.hidden) {
this.setState({ hidden: false });
this.container.focus();
} else {
this.setState({ hidden: true });
this.container.blur();
}
};
this.unFocusCallback = () => {
this.container.blur();
this.setState({ hidden: true });
};
this.loopThroughCallback = event => {
if (!this.state.foundNodes.length) {
return;
}
if (event.key === "Enter") {
const dir = event.shiftKey ? -1 : 1;
let atNode = this.state.atNode + dir;
if (atNode < 0) {
atNode = this.state.foundNodes.length - 1;
}
atNode %= this.state.foundNodes.length;
const rects = this.state.foundNodes[atNode].getBoundingClientRect();
this.viewPort.scrollBy(0, rects.y - 100);
this.setState({ atNode });
}
};
Spicetify.Mousetrap().bind("mod+shift+f", this.toggleCallback);
Spicetify.Mousetrap(this.container).bind("mod+shift+f", this.toggleCallback);
Spicetify.Mousetrap(this.container).bind("enter", this.loopThroughCallback);
Spicetify.Mousetrap(this.container).bind("shift+enter", this.loopThroughCallback);
Spicetify.Mousetrap(this.container).bind("esc", this.unFocusCallback);
}
componentWillUnmount() {
Spicetify.Mousetrap().unbind("mod+shift+f", this.toggleCallback);
Spicetify.Mousetrap(this.container).unbind("mod+shift+f", this.toggleCallback);
Spicetify.Mousetrap(this.container).unbind("enter", this.loopThroughCallback);
Spicetify.Mousetrap(this.container).unbind("shift+enter", this.loopThroughCallback);
Spicetify.Mousetrap(this.container).unbind("esc", this.unFocusCallback);
}
getNodeFromInput(event) {
const value = event.target.value.toLowerCase();
if (!value) {
this.setState({ foundNodes: [] });
this.viewPort.scrollTo(0, 0);
return;
}
const lyricsPage = document.querySelector(".lyrics-lyricsContainer-UnsyncedLyricsPage");
const walker = document.createTreeWalker(
lyricsPage,
NodeFilter.SHOW_TEXT,
node => {
if (node.textContent.toLowerCase().includes(value)) {
return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_REJECT;
},
false
);
const foundNodes = [];
while (walker.nextNode()) {
const range = document.createRange();
range.selectNodeContents(walker.currentNode);
foundNodes.push(range);
}
if (!foundNodes.length) {
this.viewPort.scrollBy(0, 0);
} else {
const rects = foundNodes[0].getBoundingClientRect();
this.viewPort.scrollBy(0, rects.y - 100);
}
this.setState({ foundNodes, atNode: 0 });
}
render() {
let y = 0;
let height = 0;
if (this.state.foundNodes.length) {
const node = this.state.foundNodes[this.state.atNode];
const rects = node.getBoundingClientRect();
y = rects.y + this.viewPort.scrollTop - this.mainViewOffsetTop;
height = rects.height;
}
return react.createElement(
"div",
{
className: `lyrics-Searchbar${this.state.hidden ? " hidden" : ""}`
},
react.createElement("input", {
ref: c => {
this.container = c;
},
onChange: this.getNodeFromInput.bind(this)
}),
react.createElement("svg", {
width: 16,
height: 16,
viewBox: "0 0 16 16",
fill: "currentColor",
dangerouslySetInnerHTML: {
__html: Spicetify.SVGIcons.search
}
}),
react.createElement(
"span",
{
hidden: this.state.foundNodes.length === 0
},
`${this.state.atNode + 1}/${this.state.foundNodes.length}`
),
react.createElement("div", {
className: "lyrics-Searchbar-highlight",
style: {
"--search-highlight-top": `${y}px`,
"--search-highlight-height": `${height}px`
}
})
);
}
}
function isInViewport(element) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
const SyncedExpandedLyricsPage = react.memo(({ lyrics, provider, copyright, isKara }) => {
const [position, setPosition] = useState(0);
const activeLineRef = useRef(null);
const pageRef = useRef(null);
useTrackPosition(() => {
if (!Spicetify.Player.data.is_paused) {
setPosition(Spicetify.Player.getProgress() + CONFIG.visual["global-delay"] + CONFIG.visual.delay);
}
});
const padded = useMemo(() => [emptyLine, ...lyrics], [lyrics]);
const intialScroll = useMemo(() => [false], [lyrics]);
const lyricsId = lyrics[0].text;
let activeLineIndex = 0;
for (let i = padded.length - 1; i >= 0; i--) {
const line = padded[i];
if (position >= line.startTime) {
activeLineIndex = i;
break;
}
}
const rawLyrics = Utils.convertParsedToLRC(lyrics);
useEffect(() => {
if (activeLineRef.current && (!intialScroll[0] || isInViewport(activeLineRef.current))) {
activeLineRef.current.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest"
});
intialScroll[0] = true;
}
}, [activeLineRef.current]);
return react.createElement(
"div",
{
className: "lyrics-lyricsContainer-UnsyncedLyricsPage",
key: lyricsId,
ref: pageRef
},
react.createElement("p", {
className: "lyrics-lyricsContainer-LyricsUnsyncedPadding"
}),
padded.map(({ text, startTime }, i) => {
if (i === 0) {
return react.createElement(IdlingIndicator, {
isActive: activeLineIndex === 0,
progress: position / padded[1].startTime,
delay: padded[1].startTime / 3
});
}
const isActive = i === activeLineIndex;
return react.createElement(
"p",
{
className: `lyrics-lyricsContainer-LyricsLine${i <= activeLineIndex ? " lyrics-lyricsContainer-LyricsLine-active" : ""}`,
style: {
cursor: "pointer"
},
dir: "auto",
ref: isActive ? activeLineRef : null,
onClick: event => {
if (startTime) {
Spicetify.Player.seek(startTime);
}
},
onContextMenu: event => {
event.preventDefault();
Spicetify.Platform.ClipboardAPI.copy(rawLyrics)
.then(() => Spicetify.showNotification("Lyrics copied to clipboard"))
.catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard"));
}
},
!isKara ? text : react.createElement(KaraokeLine, { text, startTime, position, isActive })
);
}),
react.createElement("p", {
className: "lyrics-lyricsContainer-LyricsUnsyncedPadding"
}),
react.createElement(CreditFooter, {
provider,
copyright
}),
react.createElement(SearchBar, null)
);
});
const UnsyncedLyricsPage = react.memo(({ lyrics, provider, copyright }) => {
const rawLyrics = lyrics.map(lyrics => (typeof lyrics.text !== "object" ? lyrics.text : lyrics.text?.props?.children?.[0])).join("\n");
return react.createElement(
"div",
{
className: "lyrics-lyricsContainer-UnsyncedLyricsPage"
},
react.createElement("p", {
className: "lyrics-lyricsContainer-LyricsUnsyncedPadding"
}),
lyrics.map(({ text }) => {
return react.createElement(
"p",
{
className: "lyrics-lyricsContainer-LyricsLine lyrics-lyricsContainer-LyricsLine-active",
dir: "auto",
onContextMenu: event => {
event.preventDefault();
Spicetify.Platform.ClipboardAPI.copy(rawLyrics)
.then(() => Spicetify.showNotification("Lyrics copied to clipboard"))
.catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard"));
}
},
text
);
}),
react.createElement("p", {
className: "lyrics-lyricsContainer-LyricsUnsyncedPadding"
}),
react.createElement(CreditFooter, {
provider,
copyright
}),
react.createElement(SearchBar, null)
);
});
const noteContainer = document.createElement("div");
noteContainer.classList.add("lyrics-Genius-noteContainer");
const noteDivider = document.createElement("div");
noteDivider.classList.add("lyrics-Genius-divider");
noteDivider.innerHTML = `<svg width="32" height="32" viewBox="0 0 13 4" fill="currentColor"><path d=\"M13 10L8 4.206 3 10z\"/></svg>`;
noteDivider.style.setProperty("--link-left", 0);
const noteTextContainer = document.createElement("div");
noteTextContainer.classList.add("lyrics-Genius-noteTextContainer");
noteTextContainer.onclick = event => {
event.preventDefault();
event.stopPropagation();
};
noteContainer.append(noteDivider, noteTextContainer);
function showNote(parent, note) {
if (noteContainer.parentElement === parent) {
noteContainer.remove();
return;
}
noteTextContainer.innerText = note;
parent.append(noteContainer);
const arrowPos = parent.offsetLeft - noteContainer.offsetLeft;
noteDivider.style.setProperty("--link-left", `${arrowPos}px`);
const box = noteTextContainer.getBoundingClientRect();
if (box.y + box.height > window.innerHeight) {
// Wait for noteContainer is mounted
setTimeout(() => {
noteContainer.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest"
});
}, 50);
}
}
const GeniusPage = react.memo(
({ lyrics, provider, copyright, versions, versionIndex, onVersionChange, isSplitted, lyrics2, versionIndex2, onVersionChange2 }) => {
let notes = {};
let container = null;
let container2 = null;
// Fetch notes
useEffect(() => {
if (!container) return;
notes = {};
let links = container.querySelectorAll("a");
if (isSplitted && container2) {
links = [...links, ...container2.querySelectorAll("a")];
}
for (const link of links) {
let id = link.pathname.match(/\/(\d+)\//);
if (!id) {
id = link.dataset.id;
} else {
id = id[1];
}
ProviderGenius.getNote(id).then(note => {
notes[id] = note;
link.classList.add("fetched");
});
link.onclick = event => {
event.preventDefault();
if (!notes[id]) return;
showNote(link, notes[id]);
};
}
}, [lyrics, lyrics2]);
const lyricsEl1 = react.createElement(
"div",
null,
react.createElement(VersionSelector, { items: versions, index: versionIndex, callback: onVersionChange }),
react.createElement("div", {
className: "lyrics-lyricsContainer-LyricsLine lyrics-lyricsContainer-LyricsLine-active",
ref: c => {
container = c;
},
dangerouslySetInnerHTML: {
__html: lyrics
},
onContextMenu: event => {
event.preventDefault();
const copylyrics = lyrics.replace(/<br>/g, "\n").replace(/<[^>]*>/g, "");
Spicetify.Platform.ClipboardAPI.copy(copylyrics)
.then(() => Spicetify.showNotification("Lyrics copied to clipboard"))
.catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard"));
}
})
);
const mainContainer = [lyricsEl1];
const shouldSplit = versions.length > 1 && isSplitted;
if (shouldSplit) {
const lyricsEl2 = react.createElement(
"div",
null,
react.createElement(VersionSelector, { items: versions, index: versionIndex2, callback: onVersionChange2 }),
react.createElement("div", {
className: "lyrics-lyricsContainer-LyricsLine lyrics-lyricsContainer-LyricsLine-active",
ref: c => {
container2 = c;
},
dangerouslySetInnerHTML: {
__html: lyrics2
},
onContextMenu: event => {
event.preventDefault();
const copylyrics = lyrics.replace(/<br>/g, "\n").replace(/<[^>]*>/g, "");
Spicetify.Platform.ClipboardAPI.copy(copylyrics)
.then(() => Spicetify.showNotification("Lyrics copied to clipboard"))
.catch(() => Spicetify.showNotification("Failed to copy lyrics to clipboard"));
}
})
);
mainContainer.push(lyricsEl2);
}
return react.createElement(
"div",
{
className: "lyrics-lyricsContainer-UnsyncedLyricsPage"
},
react.createElement("p", {
className: "lyrics-lyricsContainer-LyricsUnsyncedPadding main-type-ballad"
}),
react.createElement("div", { className: shouldSplit ? "split" : "" }, mainContainer),
react.createElement(CreditFooter, {
provider,
copyright
}),
react.createElement(SearchBar, null)
);
}
);
const LoadingIcon = react.createElement(
"svg",
{
width: "200px",
height: "200px",
viewBox: "0 0 100 100",
preserveAspectRatio: "xMidYMid"
},
react.createElement(
"circle",
{
cx: "50",
cy: "50",
r: "0",
fill: "none",
stroke: "currentColor",
"stroke-width": "2"
},
react.createElement("animate", {
attributeName: "r",
repeatCount: "indefinite",
dur: "1s",
values: "0;40",
keyTimes: "0;1",
keySplines: "0 0.2 0.8 1",
calcMode: "spline",
begin: "0s"
}),
react.createElement("animate", {
attributeName: "opacity",
repeatCount: "indefinite",
dur: "1s",
values: "1;0",
keyTimes: "0;1",
keySplines: "0.2 0 0.8 1",
calcMode: "spline",
begin: "0s"
})
),
react.createElement(
"circle",
{
cx: "50",
cy: "50",
r: "0",
fill: "none",
stroke: "currentColor",
"stroke-width": "2"
},
react.createElement("animate", {
attributeName: "r",
repeatCount: "indefinite",
dur: "1s",
values: "0;40",
keyTimes: "0;1",
keySplines: "0 0.2 0.8 1",
calcMode: "spline",
begin: "-0.5s"
}),
react.createElement("animate", {
attributeName: "opacity",
repeatCount: "indefinite",
dur: "1s",
values: "1;0",
keyTimes: "0;1",
keySplines: "0.2 0 0.8 1",
calcMode: "spline",
begin: "-0.5s"
})
)
);
const VersionSelector = react.memo(({ items, index, callback }) => {
if (items.length < 2) {
return null;
}
return react.createElement(
"div",
{
className: "lyrics-versionSelector"
},
react.createElement(
"select",
{
onChange: event => {
callback(items, event.target.value);
},
value: index
},
items.map((a, i) => {
return react.createElement("option", { value: i }, a.title);
})
),
react.createElement(
"svg",
{
height: "16",
width: "16",
fill: "currentColor",
viewBox: "0 0 16 16"
},
react.createElement("path", {
d: "M3 6l5 5.794L13 6z"
})
)
);
});

View file

@ -0,0 +1,48 @@
(function PlaybarButton() {
if (!Spicetify.Platform.History) {
setTimeout(PlaybarButton, 300);
return;
}
const button = new Spicetify.Playbar.Button(
"Lyrics Plus",
`<svg role="img" height="16" width="16" aria-hidden="true" viewBox="0 0 16 16" data-encore-id="icon" fill="currentColor"><path d="M13.426 2.574a2.831 2.831 0 0 0-4.797 1.55l3.247 3.247a2.831 2.831 0 0 0 1.55-4.797zM10.5 8.118l-2.619-2.62A63303.13 63303.13 0 0 0 4.74 9.075L2.065 12.12a1.287 1.287 0 0 0 1.816 1.816l3.06-2.688 3.56-3.129zM7.12 4.094a4.331 4.331 0 1 1 4.786 4.786l-3.974 3.493-3.06 2.689a2.787 2.787 0 0 1-3.933-3.933l2.676-3.045 3.505-3.99z"></path></svg>`,
() =>
Spicetify.Platform.History.location.pathname !== "/lyrics-plus"
? Spicetify.Platform.History.push("/lyrics-plus")
: Spicetify.Platform.History.goBack(),
false,
Spicetify.Platform.History.location.pathname === "/lyrics-plus",
false
);
const style = document.createElement("style");
style.innerHTML = `
.main-nowPlayingBar-lyricsButton {
display: none !important;
}
li[data-id="/lyrics-plus"] {
display: none;
}
`;
style.classList.add("lyrics-plus:visual:playbar-button");
if (Spicetify.LocalStorage.get("lyrics-plus:visual:playbar-button") === "true") setPlaybarButton();
window.addEventListener("lyrics-plus", event => {
if (event.detail?.name === "playbar-button") event.detail.value ? setPlaybarButton() : removePlaybarButton();
});
Spicetify.Platform.History.listen(location => {
button.active = location.pathname === "/lyrics-plus";
});
function setPlaybarButton() {
document.head.appendChild(style);
button.register();
}
function removePlaybarButton() {
style.remove();
button.deregister();
}
})();

View file

@ -0,0 +1,133 @@
const ProviderGenius = (() => {
function getChildDeep(parent, isDeep = false) {
let acc = "";
if (!parent.children) {
return acc;
}
for (const child of parent.children) {
if (typeof child === "string") {
acc += child;
} else if (child.children) {
acc += getChildDeep(child, true);
}
if (!isDeep) {
acc += "\n";
}
}
return acc.trim();
}
async function getNote(id) {
const body = await Spicetify.CosmosAsync.get(`https://genius.com/api/annotations/${id}`);
const response = body.response;
let note = "";
// Authors annotations
if (response.referent && response.referent.classification === "verified") {
const referentsBody = await Spicetify.CosmosAsync.get(`https://genius.com/api/referents/${id}`);
const referents = referentsBody.response;
for (const ref of referents.referent.annotations) {
note += getChildDeep(ref.body.dom);
}
}
// Users annotations
if (!note && response.annotation) {
note = getChildDeep(response.annotation.body.dom);
}
// Users comments
if (!note && response.annotation && response.annotation.top_comment) {
note += getChildDeep(response.annotation.top_comment.body.dom);
}
note = note.replace(/\n\n\n?/, "\n");
return note;
}
function fetchHTML(url) {
return new Promise((resolve, reject) => {
const request = JSON.stringify({
method: "GET",
uri: url
});
window.sendCosmosRequest({
request,
persistent: false,
onSuccess: resolve,
onFailure: reject
});
});
}
async function fetchLyricsVersion(results, index) {
const result = results[index];
if (!result) {
console.warn(result);
return;
}
const site = await fetchHTML(result.url);
const body = JSON.parse(site)?.body;
if (!body) {
return null;
}
let lyrics = "";
const parser = new DOMParser();
const htmlDoc = parser.parseFromString(body, "text/html");
const lyricsDiv = htmlDoc.querySelectorAll('div[data-lyrics-container="true"]');
for (const i of lyricsDiv) {
lyrics += `${i.innerHTML}<br>`;
}
if (!lyrics?.length) {
console.warn("forceError");
return null;
}
return lyrics;
}
async function fetchLyrics(info) {
const titles = new Set([info.title]);
const titleNoExtra = Utils.removeExtraInfo(info.title);
titles.add(titleNoExtra);
titles.add(Utils.removeSongFeat(info.title));
titles.add(Utils.removeSongFeat(titleNoExtra));
let lyrics;
let hits;
for (const title of titles) {
const query = new URLSearchParams({ per_page: 20, q: encodeURIComponent(`${title} ${info.artist}`) });
const url = `https://genius.com/api/search/song?${query.toString()}`;
const geniusSearch = await Spicetify.CosmosAsync.get(url);
hits = geniusSearch.response.sections[0].hits.map(item => ({
title: item.result.full_title,
url: item.result.url
}));
if (!hits.length) {
continue;
}
lyrics = await fetchLyricsVersion(hits, 0);
break;
}
if (!lyrics) {
return { lyrics: null, versions: [] };
}
return { lyrics, versions: hits };
}
return { fetchLyrics, getNote, fetchLyricsVersion };
})();

View file

@ -0,0 +1,53 @@
const ProviderLRCLIB = (() => {
async function findLyrics(info) {
const baseURL = "https://lrclib.net/api/get";
const durr = info.duration / 1000;
const params = {
track_name: info.title,
artist_name: info.artist,
album_name: info.album,
duration: durr
};
const finalURL = `${baseURL}?${Object.keys(params)
.map(key => `${key}=${encodeURIComponent(params[key])}`)
.join("&")}`;
const body = await fetch(finalURL, {
headers: {
"user-agent": `spicetify v${Spicetify.Config.version} (https://github.com/spicetify/cli)`
}
});
if (body.status !== 200) {
return {
error: "Request error: Track wasn't found",
uri: info.uri
};
}
return await body.json();
}
function getUnsynced(body) {
const unsyncedLyrics = body?.plainLyrics;
const isInstrumental = body.instrumental;
if (isInstrumental) return [{ text: "♪ Instrumental ♪" }];
if (!unsyncedLyrics) return null;
return Utils.parseLocalLyrics(unsyncedLyrics).unsynced;
}
function getSynced(body) {
const syncedLyrics = body?.syncedLyrics;
const isInstrumental = body.instrumental;
if (isInstrumental) return [{ text: "♪ Instrumental ♪" }];
if (!syncedLyrics) return null;
return Utils.parseLocalLyrics(syncedLyrics).synced;
}
return { findLyrics, getSynced, getUnsynced };
})();

View file

@ -0,0 +1,191 @@
const ProviderMusixmatch = (() => {
const headers = {
authority: "apic-desktop.musixmatch.com",
cookie: "x-mxm-token-guid="
};
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&";
const durr = info.duration / 1000;
const params = {
q_album: info.album,
q_artist: info.artist,
q_artists: info.artist,
q_track: info.title,
track_spotify_id: info.uri,
q_duration: durr,
f_subtitle_length: Math.floor(durr),
usertoken: CONFIG.providers.musixmatch.token
};
const finalURL =
baseURL +
Object.keys(params)
.map(key => `${key}=${encodeURIComponent(params[key])}`)
.join("&");
let body = await Spicetify.CosmosAsync.get(finalURL, null, headers);
body = body.message.body.macro_calls;
if (body["matcher.track.get"].message.header.status_code !== 200) {
return {
error: `Requested error: ${body["matcher.track.get"].message.header.mode}`,
uri: info.uri
};
}
if (body["track.lyrics.get"]?.message?.body?.lyrics?.restricted) {
return {
error: "Unfortunately we're not authorized to show these lyrics.",
uri: info.uri
};
}
return body;
}
async function getKaraoke(body) {
const meta = body?.["matcher.track.get"]?.message?.body;
if (!meta) {
return null;
}
if (!meta.track.has_richsync || meta.track.instrumental) {
return null;
}
const baseURL = "https://apic-desktop.musixmatch.com/ws/1.1/track.richsync.get?format=json&subtitle_format=mxm&app_id=web-desktop-app-v1.0&";
const params = {
f_subtitle_length: meta.track.track_length,
q_duration: meta.track.track_length,
commontrack_id: meta.track.commontrack_id,
usertoken: CONFIG.providers.musixmatch.token
};
const finalURL =
baseURL +
Object.keys(params)
.map(key => `${key}=${encodeURIComponent(params[key])}`)
.join("&");
let result = await Spicetify.CosmosAsync.get(finalURL, null, headers);
if (result.message.header.status_code !== 200) {
return null;
}
result = result.message.body;
const parsedKaraoke = JSON.parse(result.richsync.richsync_body).map(line => {
const startTime = line.ts * 1000;
const endTime = line.te * 1000;
const words = line.l;
const text = words.map((word, index, words) => {
const wordText = word.c;
const wordStartTime = word.o * 1000;
const nextWordStartTime = words[index + 1]?.o * 1000;
const time = !Number.isNaN(nextWordStartTime) ? nextWordStartTime - wordStartTime : endTime - (wordStartTime + startTime);
return {
word: wordText,
time
};
});
return {
startTime,
text
};
});
return parsedKaraoke;
}
function getSynced(body) {
const meta = body?.["matcher.track.get"]?.message?.body;
if (!meta) {
return null;
}
const hasSynced = meta?.track?.has_subtitles;
const isInstrumental = meta?.track?.instrumental;
if (isInstrumental) {
return [{ text: "♪ Instrumental ♪", startTime: "0000" }];
}
if (hasSynced) {
const subtitle = body["track.subtitles.get"]?.message?.body?.subtitle_list?.[0]?.subtitle;
if (!subtitle) {
return null;
}
return JSON.parse(subtitle.subtitle_body).map(line => ({
text: line.text || "♪",
startTime: line.time.total * 1000
}));
}
return null;
}
function getUnsynced(body) {
const meta = body?.["matcher.track.get"]?.message?.body;
if (!meta) {
return null;
}
const hasUnSynced = meta.track.has_lyrics || meta.track.has_lyrics_crowd;
const isInstrumental = meta?.track?.instrumental;
if (isInstrumental) {
return [{ text: "♪ Instrumental ♪" }];
}
if (hasUnSynced) {
const lyrics = body["track.lyrics.get"]?.message?.body?.lyrics?.lyrics_body;
if (!lyrics) {
return null;
}
return lyrics.split("\n").map(text => ({ text }));
}
return null;
}
async function getTranslation(body) {
const track_id = body?.["matcher.track.get"]?.message?.body?.track?.track_id;
if (!track_id) return null;
const baseURL =
"https://apic-desktop.musixmatch.com/ws/1.1/crowd.track.translations.get?translation_fields_set=minimal&selected_language=en&comment_format=text&format=json&app_id=web-desktop-app-v1.0&";
const params = {
track_id,
usertoken: CONFIG.providers.musixmatch.token
};
const finalURL =
baseURL +
Object.keys(params)
.map(key => `${key}=${encodeURIComponent(params[key])}`)
.join("&");
let result = await Spicetify.CosmosAsync.get(finalURL, null, headers);
if (result.message.header.status_code !== 200) return null;
result = result.message.body;
if (!result.translations_list?.length) return null;
return result.translations_list.map(({ translation }) => ({ translation: translation.description, matchedLine: translation.matched_line }));
}
return { findLyrics, getKaraoke, getSynced, getUnsynced, getTranslation };
})();

View file

@ -0,0 +1,205 @@
const ProviderNetease = (() => {
const requestHeader = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/20100101 Firefox/93.0"
};
async function findLyrics(info) {
const searchURL = "https://music.xianqiao.wang/neteaseapiv2/search?limit=10&type=1&keywords=";
const lyricURL = "https://music.xianqiao.wang/neteaseapiv2/lyric?id=";
const cleanTitle = Utils.removeExtraInfo(Utils.removeSongFeat(Utils.normalize(info.title)));
const finalURL = searchURL + encodeURIComponent(`${cleanTitle} ${info.artist}`);
const searchResults = await Spicetify.CosmosAsync.get(finalURL, null, requestHeader);
const items = searchResults.result.songs;
if (!items?.length) {
throw "Cannot find track";
}
// normalized expected album name
const neAlbumName = Utils.normalize(info.album);
const expectedAlbumName = Utils.containsHanCharacter(neAlbumName) ? await Utils.toSimplifiedChinese(neAlbumName) : neAlbumName;
let itemId = items.findIndex(val => Utils.normalize(val.album.name) === expectedAlbumName);
if (itemId === -1) itemId = items.findIndex(val => Math.abs(info.duration - val.duration) < 3000);
if (itemId === -1) itemId = items.findIndex(val => val.name === cleanTitle);
if (itemId === -1) throw "Cannot find track";
return await Spicetify.CosmosAsync.get(lyricURL + items[itemId].id, null, requestHeader);
}
const creditInfo = [
"\\s?作?\\s*词|\\s?作?\\s*曲|\\s?编\\s*曲?|\\s?监\\s*制?",
".*编写|.*和音|.*和声|.*合声|.*提琴|.*录|.*工程|.*工作室|.*设计|.*剪辑|.*制作|.*发行|.*出品|.*后期|.*混音|.*缩混",
"原唱|翻唱|题字|文案|海报|古筝|二胡|钢琴|吉他|贝斯|笛子|鼓|弦乐",
"lrc|publish|vocal|guitar|program|produce|write|mix"
];
const creditInfoRegExp = new RegExp(`^(${creditInfo.join("|")}).*(:|)`, "i");
function containCredits(text) {
return creditInfoRegExp.test(text);
}
function parseTimestamp(line) {
// ["[ar:Beyond]"]
// ["[03:10]"]
// ["[03:10]", "lyrics"]
// ["lyrics"]
// ["[03:10]", "[03:10]", "lyrics"]
// ["[1235,300]", "lyrics"]
const matchResult = line.match(/(\[.*?\])|([^\[\]]+)/g);
if (!matchResult?.length || matchResult.length === 1) {
return { text: line };
}
const textIndex = matchResult.findIndex(slice => !slice.endsWith("]"));
let text = "";
if (textIndex > -1) {
text = matchResult.splice(textIndex, 1)[0];
text = Utils.capitalize(Utils.normalize(text, false));
}
const time = matchResult[0].replace("[", "").replace("]", "");
return { time, text };
}
function breakdownLine(text) {
// (0,508)Don't(0,1) (0,151)want(0,1) (0,162)to(0,1) (0,100)be(0,1) (0,157)an(0,1)
const components = text.split(/\(\d+,(\d+)\)/g);
// ["", "508", "Don't", "1", " ", "151", "want" , "1" ...]
const result = [];
for (let i = 1; i < components.length; i += 2) {
if (components[i + 1] === " ") continue;
result.push({
word: `${components[i + 1]} `,
time: Number.parseInt(components[i])
});
}
return result;
}
function getKaraoke(list) {
const lyricStr = list?.klyric?.lyric;
if (!lyricStr) {
return null;
}
const lines = lyricStr.split(/\r?\n/).map(line => line.trim());
const karaoke = lines
.map(line => {
const { time, text } = parseTimestamp(line);
if (!time || !text) return null;
const [key, value] = time.split(",") || [];
const [start, durr] = [Number.parseFloat(key), Number.parseFloat(value)];
if (!Number.isNaN(start) && !Number.isNaN(durr) && !containCredits(text)) {
return {
startTime: start,
// endTime: start + durr,
text: breakdownLine(text)
};
}
return null;
})
.filter(Boolean);
if (!karaoke.length) {
return null;
}
return karaoke;
}
function getSynced(list) {
const lyricStr = list?.lrc?.lyric;
let noLyrics = false;
if (!lyricStr) {
return null;
}
const lines = lyricStr.split(/\r?\n/).map(line => line.trim());
const lyrics = lines
.map(line => {
const { time, text } = parseTimestamp(line);
if (text === "纯音乐, 请欣赏") noLyrics = true;
if (!time || !text) return null;
const [key, value] = time.split(":") || [];
const [min, sec] = [Number.parseFloat(key), Number.parseFloat(value)];
if (!Number.isNaN(min) && !Number.isNaN(sec) && !containCredits(text)) {
return {
startTime: (min * 60 + sec) * 1000,
text: text || ""
};
}
return null;
})
.filter(Boolean);
if (!lyrics.length || noLyrics) {
return null;
}
return lyrics;
}
function getTranslation(list) {
const lyricStr = list?.tlyric?.lyric;
if (!lyricStr) {
return null;
}
const lines = lyricStr.split(/\r?\n/).map(line => line.trim());
const translation = lines
.map(line => {
const { time, text } = parseTimestamp(line);
if (!time || !text) return null;
const [key, value] = time.split(":") || [];
const [min, sec] = [Number.parseFloat(key), Number.parseFloat(value)];
if (!Number.isNaN(min) && !Number.isNaN(sec) && !containCredits(text)) {
return {
startTime: (min * 60 + sec) * 1000,
text: text || ""
};
}
return null;
})
.filter(Boolean);
if (!translation.length) {
return null;
}
return translation;
}
function getUnsynced(list) {
const lyricStr = list?.lrc?.lyric;
let noLyrics = false;
if (!lyricStr) {
return null;
}
const lines = lyricStr.split(/\r?\n/).map(line => line.trim());
const lyrics = lines
.map(line => {
const parsed = parseTimestamp(line);
if (parsed.text === "纯音乐, 请欣赏") noLyrics = true;
if (!parsed.text || containCredits(parsed.text)) return null;
return parsed;
})
.filter(Boolean);
if (!lyrics.length || noLyrics) {
return null;
}
return lyrics;
}
return { findLyrics, getKaraoke, getSynced, getUnsynced, getTranslation };
})();

View file

@ -0,0 +1,213 @@
const Providers = {
spotify: async info => {
const result = {
uri: info.uri,
karaoke: null,
synced: null,
unsynced: null,
provider: "Spotify",
copyright: null
};
const baseURL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/";
const id = info.uri.split(":")[2];
let body;
try {
body = await Spicetify.CosmosAsync.get(`${baseURL + id}?format=json&vocalRemoval=false&market=from_token`);
} catch {
return { error: "Request error", uri: info.uri };
}
const lyrics = body.lyrics;
if (!lyrics) {
return { error: "No lyrics", uri: info.uri };
}
const lines = lyrics.lines;
if (lyrics.syncType === "LINE_SYNCED") {
result.synced = lines.map(line => ({
startTime: line.startTimeMs,
text: line.words
}));
result.unsynced = result.synced;
} else {
result.unsynced = lines.map(line => ({
text: line.words
}));
}
result.provider = lyrics.provider;
return result;
},
musixmatch: async info => {
const result = {
error: null,
uri: info.uri,
karaoke: null,
synced: null,
unsynced: null,
musixmatchTranslation: null,
provider: "Musixmatch",
copyright: null
};
let list;
try {
list = await ProviderMusixmatch.findLyrics(info);
if (list.error) {
throw "";
}
} catch {
result.error = "No lyrics";
return result;
}
const karaoke = await ProviderMusixmatch.getKaraoke(list);
if (karaoke) {
result.karaoke = karaoke;
result.copyright = list["track.lyrics.get"].message?.body?.lyrics?.lyrics_copyright?.trim();
}
const synced = ProviderMusixmatch.getSynced(list);
if (synced) {
result.synced = synced;
result.copyright = list["track.subtitles.get"].message?.body?.subtitle_list?.[0]?.subtitle.lyrics_copyright.trim();
}
const unsynced = synced || ProviderMusixmatch.getUnsynced(list);
if (unsynced) {
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) {
const baseLyrics = synced ?? unsynced;
result.musixmatchTranslation = baseLyrics.map(line => ({
...line,
text: translation.find(t => t.matchedLine === line.text)?.translation ?? line.text,
originalText: line.text
}));
}
return result;
},
netease: async info => {
const result = {
uri: info.uri,
karaoke: null,
synced: null,
unsynced: null,
neteaseTranslation: null,
provider: "Netease",
copyright: null
};
let list;
try {
list = await ProviderNetease.findLyrics(info);
} catch {
result.error = "No lyrics";
return result;
}
const karaoke = ProviderNetease.getKaraoke(list);
if (karaoke) {
result.karaoke = karaoke;
}
const synced = ProviderNetease.getSynced(list);
if (synced) {
result.synced = synced;
}
const unsynced = synced || ProviderNetease.getUnsynced(list);
if (unsynced) {
result.unsynced = unsynced;
}
const translation = ProviderNetease.getTranslation(list);
if (translation) {
result.neteaseTranslation = translation;
}
return result;
},
lrclib: async info => {
const result = {
uri: info.uri,
karaoke: null,
synced: null,
unsynced: null,
provider: "lrclib",
copyright: null
};
let list;
try {
list = await ProviderLRCLIB.findLyrics(info);
} catch {
result.error = "No lyrics";
return result;
}
const synced = ProviderLRCLIB.getSynced(list);
if (synced) {
result.synced = synced;
}
const unsynced = synced || ProviderLRCLIB.getUnsynced(list);
if (unsynced) {
result.unsynced = unsynced;
}
return result;
},
genius: async info => {
const { lyrics, versions } = await ProviderGenius.fetchLyrics(info);
let versionIndex2 = 0;
let genius2 = lyrics;
if (CONFIG.visual["dual-genius"] && versions.length > 1) {
genius2 = await ProviderGenius.fetchLyricsVersion(versions, 1);
versionIndex2 = 1;
}
return {
uri: info.uri,
genius: lyrics,
provider: "Genius",
karaoke: null,
synced: null,
unsynced: null,
copyright: null,
error: null,
versions,
versionIndex: 0,
genius2,
versionIndex2
};
},
local: info => {
let result = {
uri: info.uri,
karaoke: null,
synced: null,
unsynced: null,
provider: "local"
};
try {
const savedLyrics = JSON.parse(localStorage.getItem("lyrics-plus:local-lyrics"));
const lyrics = savedLyrics[info.uri];
if (!lyrics) {
throw "";
}
result = {
...result,
...lyrics
};
} catch {
result.error = "No lyrics";
}
return result;
}
};

View file

@ -0,0 +1,46 @@
# Spicetify Custom App
### Lyrics Plus
Show current track lyrics. Current lyrics providers:
- Internal Spotify lyrics service.
- Netease: From Chinese developers and users. Provides karaoke and synced lyrics.
- Musixmatch: A company from Italy. Provided synced lyrics.
- Genius: Provide unsynced lyrics but with description/insight from artists themselve.
![kara](./kara.png)
![genius](./genius.png)
Different lyrics modes: Karaoke, Synced, Unsynced and Genius. At the moment, only Netease provides karaoke-able lyrics. Mode is automatically falled back, from Karaoke, Synced, Unsynced to Genius when lyrics are not available in that mode.
Right click or Double click at any mode tab to "lock in", so lyric mode won't auto switch. It should show a dot next to mode name when mode is locked. Right click or double click again to unlock
![lockin](./lockin.png)
Lyrics in Unsynced and Genius modes can be search and jump to. Hit Ctrl + Shift + F to open search box at bottom left of screen. Hit Enter/Shift+Enter to loop over results.
![search](./search.png)
Choose between different option of displaying Japanese lyrics. (Furigana, Romaji, Hiragana, Katakana)
![conversion](./conversion.png)
Customise colors, change providers' priorities in config menu. Config menu locates in Profile Menu (top right button with your user name).
To install, run:
```bash
spicetify config custom_apps lyrics-plus
spicetify apply
```
### Credits
- A few parts of app code are taken from Spotify official app, including SyncedLyricsPage, CSS animation and TabBar. Please do not distribute these code else where out of Spotify/Spicetify context.
- Netease synced lyrics parser is adapted from [mantou132/Spotify-Lyrics](https://github.com/mantou132/Spotify-Lyrics). Give it a Star if you like this app.
- The algorithm for converting Japanese lyrics is based on [Hexenq's Kuroshiro](https://github.com/hexenq/kuroshiro).
- The algorithm for converting Chinese lyrics is based on [BYVoid's OpenCC](https://github.com/BYVoid/OpenCC) via [nk2028's opencc-js](https://github.com/nk2028/opencc-js).
- The algorithm for converting Korean lyrics is based on [fujaru's aromanize-js](https://github.com/fujaru/aromanize-js)
- The algorithm for detecting Simplified Chinese is adapted from [nickdrewe's traditional-or-simplified](https://github.com/nickdrewe/traditional-or-simplified).

View file

@ -0,0 +1,657 @@
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
});
}

View file

@ -0,0 +1,204 @@
class TabBarItem extends react.Component {
onSelect(event) {
event.preventDefault();
this.props.switchTo(this.props.item.key);
}
onLock(event) {
event.preventDefault();
this.props.lockIn(this.props.item.key);
}
render() {
return react.createElement(
"li",
{
className: "lyrics-tabBar-headerItem",
onClick: this.onSelect.bind(this),
onDoubleClick: this.onLock.bind(this),
onContextMenu: this.onLock.bind(this)
},
react.createElement(
"a",
{
"aria-current": "page",
className: `lyrics-tabBar-headerItemLink ${this.props.item.active ? "lyrics-tabBar-active" : ""}`,
draggable: "false",
href: ""
},
react.createElement(
"span",
{
className: "main-type-mestoBold"
},
this.props.item.value
)
)
);
}
}
const TabBarMore = react.memo(({ items, switchTo, lockIn }) => {
const activeItem = items.find(item => item.active);
function onLock(event) {
event.preventDefault();
if (activeItem) {
lockIn(activeItem.key);
}
}
return react.createElement(
"li",
{
className: `lyrics-tabBar-headerItem ${activeItem ? "lyrics-tabBar-active" : ""}`,
onDoubleClick: onLock,
onContextMenu: onLock
},
react.createElement(OptionsMenu, {
options: items,
onSelect: switchTo,
selected: activeItem,
defaultValue: "More",
bold: true
})
);
});
const TopBarContent = ({ links, activeLink, lockLink, switchCallback, lockCallback }) => {
const resizeHost =
document.querySelector(".Root__main-view .os-resize-observer-host") ?? document.querySelector(".Root__main-view .os-size-observer");
const [windowSize, setWindowSize] = useState(resizeHost.clientWidth);
const resizeHandler = () => setWindowSize(resizeHost.clientWidth);
useEffect(() => {
const observer = new ResizeObserver(resizeHandler);
observer.observe(resizeHost);
return () => {
observer.disconnect();
};
}, [resizeHandler]);
return react.createElement(
TabBarContext,
null,
react.createElement(TabBar, {
className: "queue-queueHistoryTopBar-tabBar",
links,
activeLink,
lockLink,
switchCallback,
lockCallback,
windowSize
})
);
};
const TabBarContext = ({ children }) => {
return reactDOM.createPortal(
react.createElement(
"div",
{
className: "main-topBar-topbarContent"
},
children
),
document.querySelector(".main-topBar-topbarContentWrapper")
);
};
const TabBar = react.memo(({ links, activeLink, lockLink, switchCallback, lockCallback, windowSize = Number.POSITIVE_INFINITY }) => {
const tabBarRef = react.useRef(null);
const [childrenSizes, setChildrenSizes] = useState([]);
const [availableSpace, setAvailableSpace] = useState(0);
const [droplistItem, setDroplistItems] = useState([]);
const options = [];
for (let i = 0; i < links.length; i++) {
const key = links[i];
if (spotifyVersion >= "1.2.31" && key === "genius") continue;
let value = key[0].toUpperCase() + key.slice(1);
if (key === lockLink) value = `${value}`;
const active = key === activeLink;
options.push({ key, value, active });
}
useEffect(() => {
if (!tabBarRef.current) return;
setAvailableSpace(tabBarRef.current.clientWidth);
}, [windowSize]);
useEffect(() => {
if (!tabBarRef.current) return;
const tabbarItemSizes = [];
for (const child of tabBarRef.current.children) {
tabbarItemSizes.push(child.clientWidth);
}
setChildrenSizes(tabbarItemSizes);
}, [links]);
useEffect(() => {
if (!tabBarRef.current) return;
const totalSize = childrenSizes.reduce((a, b) => a + b, 0);
// Can we render everything?
if (totalSize <= availableSpace) {
setDroplistItems([]);
return;
}
// The `More` button can be set to _any_ of the children. So we
// reserve space for the largest item instead of always taking
// the last item.
const viewMoreButtonSize = Math.max(...childrenSizes);
// Figure out how many children we can render while also showing
// the More button
const itemsToHide = [];
let stopWidth = viewMoreButtonSize;
childrenSizes.forEach((childWidth, i) => {
if (availableSpace >= stopWidth + childWidth) {
stopWidth += childWidth;
} else {
// First elem is edit button
itemsToHide.push(i);
}
});
setDroplistItems(itemsToHide);
}, [availableSpace, childrenSizes]);
return react.createElement(
"nav",
{
className: "lyrics-tabBar lyrics-tabBar-nav"
},
react.createElement(
"ul",
{
className: "lyrics-tabBar-header",
ref: tabBarRef
},
react.createElement("li", {
className: "lyrics-tabBar-headerItem"
}),
options
.filter((_, id) => !droplistItem.includes(id))
.map(item =>
react.createElement(TabBarItem, {
item,
switchTo: switchCallback,
lockIn: lockCallback
})
),
droplistItem.length || childrenSizes.length === 0
? react.createElement(TabBarMore, {
items: droplistItem.map(i => options[i]).filter(Boolean),
switchTo: switchCallback,
lockIn: lockCallback
})
: null
)
);
});

View file

@ -0,0 +1,146 @@
const kuroshiroPath = "https://cdn.jsdelivr.net/npm/kuroshiro@1.2.0/dist/kuroshiro.min.js";
const kuromojiPath = "https://cdn.jsdelivr.net/npm/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js";
const aromanize = "https://cdn.jsdelivr.net/npm/aromanize@0.1.5/aromanize.min.js";
const openCCPath = "https://cdn.jsdelivr.net/npm/opencc-js@1.0.5/dist/umd/full.min.js";
const dictPath = "https:/cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict";
class Translator {
constructor(lang) {
this.finished = {
ja: false,
ko: false,
zh: false
};
this.applyKuromojiFix();
this.injectExternals(lang);
this.createTranslator(lang);
}
includeExternal(url) {
if (CONFIG.visual.translate && !document.querySelector(`script[src="${url}"]`)) {
const script = document.createElement("script");
script.setAttribute("type", "text/javascript");
script.setAttribute("src", url);
document.head.appendChild(script);
}
}
injectExternals(lang) {
switch (lang?.slice(0, 2)) {
case "ja":
this.includeExternal(kuromojiPath);
this.includeExternal(kuroshiroPath);
break;
case "ko":
this.includeExternal(aromanize);
break;
case "zh":
this.includeExternal(openCCPath);
break;
}
}
/**
* Fix an issue with kuromoji when loading dict from external urls
* Adapted from: https://github.com/mobilusoss/textlint-browser-runner/pull/7
*/
applyKuromojiFix() {
if (typeof XMLHttpRequest.prototype.realOpen !== "undefined") return;
XMLHttpRequest.prototype.realOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, bool) {
if (url.indexOf(dictPath.replace("https://", "https:/")) === 0) {
this.realOpen(method, url.replace("https:/", "https://"), bool);
} else {
this.realOpen(method, url, bool);
}
};
}
async createTranslator(lang) {
switch (lang.slice(0, 2)) {
case "ja":
if (this.kuroshiro) return;
if (typeof Kuroshiro === "undefined" || typeof KuromojiAnalyzer === "undefined") {
await Translator.#sleep(50);
return this.createTranslator(lang);
}
this.kuroshiro = new Kuroshiro.default();
this.kuroshiro.init(new KuromojiAnalyzer({ dictPath })).then(
function () {
this.finished.ja = true;
}.bind(this)
);
break;
case "ko":
if (this.Aromanize) return;
if (typeof Aromanize === "undefined") {
await Translator.#sleep(50);
return this.createTranslator(lang);
}
this.Aromanize = Aromanize;
this.finished.ko = true;
break;
case "zh":
if (this.OpenCC) return;
if (typeof OpenCC === "undefined") {
await Translator.#sleep(50);
return this.createTranslator(lang);
}
this.OpenCC = OpenCC;
this.finished.zh = true;
break;
}
}
async romajifyText(text, target = "romaji", mode = "spaced") {
if (!this.finished.ja) {
await Translator.#sleep(100);
return this.romajifyText(text, target, mode);
}
return this.kuroshiro.convert(text, {
to: target,
mode: mode
});
}
async convertToRomaja(text, target) {
if (!this.finished.ko) {
await Translator.#sleep(100);
return this.convertToRomaja(text, target);
}
if (target === "hangul") return text;
return Aromanize.hangulToLatin(text, "rr-translit");
}
async convertChinese(text, from, target) {
if (!this.finished.zh) {
await Translator.#sleep(100);
return this.convertChinese(text, from, target);
}
const converter = this.OpenCC.Converter({
from: from,
to: target
});
return converter(text);
}
/**
* Async wrapper of `setTimeout`.
*
* @param {number} ms
* @returns {Promise<void>}
*/
static async #sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

View file

@ -0,0 +1,963 @@
// Run "npm i @types/react" to have this type package available in workspace
/// <reference types="react" />
/// <reference path="../../globals.d.ts" />
/** @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 <code>Refresh Token</code> 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 <code>1.2.31</code> 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("<br>").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 => `<strong>${key}</strong>`);
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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View file

@ -0,0 +1,84 @@
{
"name": {
"ms": "Lyrics",
"gu": "Lyrics",
"ko": "Lyrics",
"pa-IN": "Lyrics",
"az": "Lyrics",
"ru": "Текст",
"uk": "Lyrics",
"nb": "Lyrics",
"sv": "Låttext",
"sw": "Lyrics",
"ur": "Lyrics",
"bho": "Lyrics",
"pa-PK": "Lyrics",
"te": "Lyrics",
"ro": "Lyrics",
"vi": "Lời bài hát",
"am": "Lyrics",
"bn": "Lyrics",
"en": "Lyrics",
"id": "Lirik",
"bg": "Lyrics",
"da": "Lyrics",
"es-419": "Letras",
"mr": "Lyrics",
"ml": "Lyrics",
"th": "เนื้อเพลง",
"tr": "Şarkı Sözleri",
"is": "Lyrics",
"fa": "Lyrics",
"or": "Lyrics",
"he": "Lyrics",
"hi": "Lyrics",
"zh-TW": "歌詞",
"sr": "Lyrics",
"pt-BR": "Letra",
"zu": "Lyrics",
"nl": "Songteksten",
"es": "Letra",
"lt": "Lyrics",
"ja": "歌詞",
"st": "Lyrics",
"it": "Lyrics",
"el": "Στίχοι",
"pt-PT": "Lyrics",
"kn": "Lyrics",
"de": "Songtext",
"fr": "Paroles",
"ne": "Lyrics",
"ar": "الكلمات",
"af": "Lyrics",
"et": "Lyrics",
"pl": "Tekst",
"ta": "Lyrics",
"sl": "Lyrics",
"pk": "Lyrics",
"hr": "Lyrics",
"sk": "Lyrics",
"fi": "Sanat",
"lv": "Lyrics",
"fil": "Lyrics",
"fr-CA": "Paroles",
"cs": "Text",
"zh-CN": "歌词",
"hu": "Dalszöveg"
},
"icon": "<svg viewBox=\"0 0 256 256\" fill=\"currentColor\" stroke=\"currentColor\" stroke-width=\"1\"><path d=\"m224.9832,74.42656q0,17.80336 -8.28326,33.3279t-22.6169,25.4232t-31.90855,12.31993l-88.73891,102.6898q-4.89793,5.69708 -12.53293,5.69708q-4.46576,0 -8.35529,-2.1364l-15.41406,-8.83047q-5.47415,-3.13339 -7.41892,-9.11532t0.5042,-11.67901l54.74154,-121.34772q-5.18604,-12.81842 -5.18604,-26.34898q0,-29.6248 21.32039,-50.70398t51.28418,-21.07918t51.28418,21.07918t21.32039,50.70398zm-158.46235,167.92132l83.26476,-96.28059q-18.72737,-0.71213 -34.50157,-10.0411t-25.13789,-24.85349l-51.57229,114.65366q-1.15245,2.56368 -0.28811,5.19858t3.3133,4.05917l15.55812,8.97289q2.30491,1.28184 4.96996,0.85456t4.39373,-2.56368zm85.85778,-105.11106q26.21832,0 44.87366,-18.44428t18.65534,-44.36598t-18.65534,-44.36598t-44.87366,-18.44428t-44.87366,18.44428t-18.65534,44.36598t18.65534,44.36598t44.87366,18.44428z\"/></svg>",
"active-icon": "<svg viewBox=\"0 0 256 256\" fill=\"currentColor\" stroke=\"currentColor\" stroke-width=\"1\"><path d=\"M 224.983 74.427 C 224.983 86.295 222.222 97.405 216.7 107.754 C 211.178 118.104 203.639 126.579 194.083 133.178 C 184.527 139.777 173.891 143.883 162.174 145.498 L 73.436 248.187 C 70.17 251.985 65.993 253.884 60.903 253.884 C 57.925 253.884 55.14 253.172 52.547 251.748 L 37.133 242.918 C 33.484 240.829 31.011 237.79 29.714 233.802 C 28.418 229.814 28.586 225.921 30.219 222.123 L 84.96 100.776 C 81.503 92.23 79.774 83.447 79.774 74.427 C 79.774 54.677 86.881 37.775 101.094 23.723 C 115.308 9.67 132.403 2.643 152.379 2.643 C 172.355 2.643 189.449 9.67 203.663 23.723 C 217.876 37.775 224.983 54.677 224.983 74.427 Z M 152.379 137.237 C 169.858 137.237 184.815 131.089 197.252 118.793 C 209.689 106.496 215.908 91.708 215.908 74.427 C 215.908 57.145 209.689 42.357 197.252 30.061 C 184.815 17.764 169.858 11.616 152.379 11.616 C 134.9 11.616 119.942 17.764 107.505 30.061 C 95.068 42.357 88.85 57.145 88.85 74.427 C 88.85 91.708 95.068 106.496 107.505 118.793 C 119.942 131.089 134.9 137.237 152.379 137.237 Z\"/></svg>",
"subfiles": [
"ProviderNetease.js",
"ProviderMusixmatch.js",
"ProviderGenius.js",
"ProviderLRCLIB.js",
"Providers.js",
"Pages.js",
"OptionsMenu.js",
"TabBar.js",
"Utils.js",
"Settings.js",
"Translator.js"
],
"subfiles_extension": ["PlaybarButton.js"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View file

@ -0,0 +1,714 @@
/*!
* Bootstrap v3.3.7 (http://getbootstrap.com)
* Copyright 2011-2016 Twitter,
Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
/*!
* Generated using the Bootstrap Customizer (http://getbootstrap.com/customize/?id=35378cd201a131f69c68a64bc4438544)
* Config saved to config.json and https://gist.github.com/35378cd201a131f69c68a64bc4438544
*/
@media (min-width: 768px) {
.container {
width: 750px;
}
}
@media (min-width: 992px) {
.container {
width: 970px;
}
}
@media (min-width: 1200px) {
.container {
width: 1170px;
}
}
@media (min-width: 1500px) {
.container {
width: 1450px;
}
}
.row {
margin-left: -16px;
margin-right: -16px;
}
.container:after,
.row:after {
clear: both;
}
.hide {
display: none !important;
}
.show {
display: block !important;
}
.hidden {
display: none !important;
}
.lyrics-lyricsContainer-LyricsContainer {
display: grid;
grid-template-rows: 1fr;
position: absolute;
height: 100%;
width: 100%;
top: 0;
}
.lyrics-lyricsContainer-Loading {
align-self: center;
grid-area: 1/1/-1/-1;
}
.lyrics-lyricsContainer-LyricsUnavailablePage {
align-items: center;
color: var(--lyrics-color-inactive);
display: flex;
grid-area: 1/1/-1/-1;
height: 100%;
justify-content: center;
padding: 20px;
font-size: 88px;
letter-spacing: 0.1em;
font-weight: 700;
}
.lyrics-lyricsContainer-UnsyncedLyricsPage {
grid-area: 1/1/-1/-1;
grid-template-rows: 1fr 20px;
user-select: text;
text-align: var(--lyrics-align-text);
}
.lyrics-lyricsContainer-LyricsUnsyncedPadding {
display: flex;
/* 2 padding blocks & 1 line height & Provider block */
height: calc(50vh - 91px - 8px - var(--lyrics-font-size));
}
.lyrics-lyricsContainer-UnsyncedLyricsPage:has(.lyrics-versionSelector, .lyrics-lyricsContainer-LyricsLine:nth-child(4))
.lyrics-lyricsContainer-LyricsUnsyncedPadding {
height: 10vh;
}
.lyrics-lyricsContainer-SyncedLyricsPage {
display: grid;
grid-area: 1/1/-1/-1;
grid-template-rows: 1fr 30px;
overflow: hidden;
text-align: var(--lyrics-align-text);
user-select: text;
}
.lyrics-lyricsContainer-LyricsBackground {
background-color: var(--lyrics-color-background);
background-image: var(--lyrics-background-noise);
grid-area: 1/1/-1/-1;
transition: background-color 0.25s ease-out;
}
.lyrics-lyricsContainer-Provider {
align-self: end;
color: var(--lyrics-color-inactive);
grid-area: 2/1/-1/-1;
justify-self: stretch;
height: 25px;
overflow: hidden;
background: linear-gradient(0deg, var(--lyrics-color-background) 30%, transparent);
z-index: 1;
padding: 60px 20px 30px;
pointer-events: none;
}
.lyrics-lyricsContainer-SyncedLyrics {
--lyrics-line-height: calc(4px + var(--lyrics-font-size));
grid-area: 1/1/-2/-1;
height: 0;
}
.lyrics-lyricsContainer-SyncedLyrics .lyrics-lyricsContainer-LyricsLine {
transform: translateY(calc(var(--position-index) * var(--lyrics-line-height) + var(--offset)));
transform-origin: var(--lyrics-align-text);
transition-timing-function: cubic-bezier(0, 0, 0.58, 1);
transition-duration: calc(var(--animation-index) * var(--animation-tempo) + 0.1s);
transition-property: transform, color, opacity;
}
.lyrics-lyricsContainer-LyricsContainer.blur-enabled .lyrics-lyricsContainer-SyncedLyrics .lyrics-lyricsContainer-LyricsLine {
filter: blur(calc(var(--blur-index) * 1.5px));
}
.lyrics-lyricsContainer-UnsyncedLyricsPage .lyrics-lyricsContainer-LyricsLine a {
color: var(--lyrics-color-active);
}
.lyrics-lyricsContainer-UnsyncedLyricsPage .lyrics-lyricsContainer-LyricsLine {
color: var(--lyrics-color-inactive);
transition: color 0.25s cubic-bezier(0, 0, 0.58, 1);
}
.lyrics-lyricsContainer-UnsyncedLyricsPage .lyrics-lyricsContainer-LyricsLine-active {
color: var(--lyrics-color-active);
}
.lyrics-lyricsContainer-UnsyncedLyricsPage .lyrics-lyricsContainer-LyricsLine:hover {
color: var(--lyrics-color-active);
}
.lyrics-lyricsContainer-SyncedLyrics .lyrics-lyricsContainer-LyricsLine {
color: var(--lyrics-color-inactive);
}
.lyrics-lyricsContainer-SyncedLyrics .lyrics-lyricsContainer-LyricsLine:hover {
color: var(--lyrics-color-active);
}
.lyrics-lyricsContainer-SyncedLyrics .lyrics-lyricsContainer-LyricsLine.lyrics-lyricsContainer-LyricsLine-active {
color: var(--lyrics-color-active);
opacity: 1;
transform: translateY(calc(var(--position-index) * var(--lyrics-line-height) + var(--offset))) scale(1.1);
filter: none !important;
}
.lyrics-lyricsContainer-SyncedLyrics > .lyrics-lyricsContainer-LyricsLine-paddingLine {
opacity: 0;
pointer-events: none;
}
.lyrics-lyricsContainer-LyricsLine,
.lyrics-versionSelector {
margin-left: 100px;
margin-right: 100px;
}
@media (min-width: 1024px) {
.lyrics-lyricsContainer-LyricsLine,
.lyrics-versionSelector {
margin-left: 150px;
margin-right: 150px;
}
}
@media (min-width: 1280px) {
.lyrics-lyricsContainer-LyricsLine,
.lyrics-versionSelector {
margin-left: 200px;
margin-right: 200px;
}
}
.lyrics-lyricsContainer-UnsyncedLyricsPage .lyrics-lyricsContainer-LyricsLine {
font-size: var(--lyrics-font-size);
font-weight: 700;
letter-spacing: -0.04em;
line-height: calc(12px + var(--lyrics-font-size));
}
.lyrics-lyricsContainer-SyncedLyrics .lyrics-lyricsContainer-LyricsLine {
font-size: var(--lyrics-font-size);
font-weight: 700;
letter-spacing: -0.04em;
line-height: var(--lyrics-line-height);
}
@media (min-width: 1280px) {
.lyrics-lyricsContainer-SyncedLyrics .lyrics-lyricsContainer-LyricsLine {
font-weight: 900;
}
}
.lyrics-tabBar-headerItem {
-webkit-app-region: no-drag;
display: inline-block;
pointer-events: auto;
}
.lyrics-tabBar-headerItemLink {
margin: 0 8px 0 0;
}
.lyrics-tabBar-active {
background-color: var(--spice-tab-active);
border-radius: 4px;
}
.lyrics-tabBar-headerItemLink {
border-radius: 4px;
color: var(--spice-text);
display: inline-block;
margin: 0 8px;
padding: 8px 16px;
position: relative;
text-decoration: none !important;
cursor: pointer;
}
.lyrics-tabBar-headerItemLink .main-type-mestoBold {
text-transform: capitalize;
}
.lyrics-tabBar-headerItemLink-locked::before {
content: "• ";
}
.lyrics-tabBar-nav {
-webkit-app-region: drag;
pointer-events: none;
width: 100%;
}
.lyrics-tabBar-header {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-start;
}
.lyrics-tabBar-headerItem .optionsMenu-dropBox {
color: var(--spice-text);
border: 0;
max-width: 150px;
height: 42px;
padding: 0 30px 0 12px;
background-color: initial;
cursor: pointer;
appearance: none;
}
.lyrics-tabBar-headerItem .optionsMenu-dropBox svg {
position: absolute;
margin-left: 8px;
}
#lyrics-plus-config-container option {
background-color: var(--spice-button);
}
div.lyrics-tabBar-headerItemLink {
padding: 0;
}
.lyrics-tabBar-header button.switch {
margin-inline-end: 12px;
margin-inline-start: 0;
}
.lyrics-lyricsContainer-Karaoke-WordActive {
color: var(--lyrics-color-active) !important;
}
.lyrics-lyricsContainer-Karaoke-Word {
color: var(--lyrics-color-inactive);
transition: var(--word-duration) color ease;
}
.lyrics-lyricsContainer-LyricsLine a {
background-color: transparent;
transition: background-color 0.25s cubic-bezier(0, 0, 0, 1);
}
.lyrics-lyricsContainer-LyricsLine a.fetched {
background-color: var(--lyrics-highlight-background);
}
.lyrics-lyricsContainer-LyricsLine a,
.lyrics-lyricsContainer-LyricsLine a:hover {
text-decoration: none !important;
}
.lyrics-lyricsContainer-LyricsLine a:hover {
border-bottom: 2px solid var(--lyrics-color-active);
}
.lyrics-Genius-noteTextContainer {
font-size: 18px;
font-weight: 400;
letter-spacing: normal;
line-height: 24px;
text-transform: none;
padding: 25px;
background-color: var(--lyrics-color-active);
border-radius: 3px;
color: var(--lyrics-highlight-background);
box-shadow: 0 10px 15px 5px rgb(0, 0, 0, 0.2);
cursor: default;
text-align: left;
}
.lyrics-Genius-divider {
/* border-bottom: 3px solid var(--lyrics-color-active); */
line-height: 0;
margin-left: var(--link-left);
}
.lyrics-Searchbar {
position: sticky;
width: 300px;
height: 40px;
bottom: 10px;
display: flex;
background-color: var(--lyrics-color-active) !important;
color: var(--lyrics-highlight-background);
margin-left: 10px;
border-radius: 3px;
box-shadow: 0 10px 15px 5px rgb(0, 0, 0, 0.2);
}
.lyrics-Searchbar input {
width: 300px;
height: 40px;
bottom: 10px;
border: 0;
color: var(--lyrics-highlight-background) !important;
padding: 0 36px;
}
.lyrics-Searchbar svg {
position: absolute;
left: 0;
height: 40px;
margin-left: 10px;
}
.lyrics-Searchbar span {
position: relative;
right: 0;
line-height: 40px;
margin-right: 10px;
font-weight: 400;
font-size: 16px;
letter-spacing: 0.2em;
}
.lyrics-Searchbar-highlight {
position: fixed;
width: 100%;
height: var(--search-highlight-height);
left: 0;
top: var(--search-highlight-top);
background-color: var(--lyrics-highlight-background);
opacity: 0.5;
pointer-events: none;
}
.lyrics-versionSelector {
max-width: 500px;
border-radius: 4px;
display: inline-block;
position: relative;
cursor: pointer;
box-shadow: 0 10px 15px 5px rgb(0, 0, 0, 0.2);
background-color: var(--lyrics-highlight-background);
margin-bottom: 75px;
}
.lyrics-versionSelector select {
border: 0;
border-radius: 4px;
max-width: 500px;
height: 42px;
padding: 0 30px 0 12px;
background-color: initial;
cursor: pointer;
appearance: none;
font-size: 18px;
background-color: var(--lyrics-color-active);
color: var(--lyrics-highlight-background);
}
.lyrics-versionSelector option {
background-color: var(--lyrics-color-active);
}
.lyrics-versionSelector svg {
position: absolute;
height: 42px;
right: 10px;
pointer-events: none;
fill: var(--lyrics-highlight-background);
}
/** Setting menu */
.lyrics-tooltip-wrapper .setting-row::after,
#lyrics-plus-config-container .setting-row::after {
content: "";
display: table;
clear: both;
}
.lyrics-tooltip-wrapper .setting-row .col,
#lyrics-plus-config-container .setting-row .col {
padding: 16px 0 4px;
align-items: center;
}
.lyrics-tooltip-wrapper .setting-row .col.description,
#lyrics-plus-config-container .setting-row .col.description {
float: left;
padding-right: 15px;
cursor: default;
}
.lyrics-tooltip-wrapper .setting-row .col.action,
#lyrics-plus-config-container .setting-row .col.action {
float: right;
display: flex;
justify-content: flex-end;
align-items: center;
}
.lyrics-tooltip-wrapper button.switch,
#lyrics-plus-config-container button.switch {
align-items: center;
border: 0px;
border-radius: 50%;
background-color: rgba(var(--spice-rgb-shadow), 0.7);
color: var(--spice-text);
cursor: pointer;
margin-inline-start: 12px;
padding: 8px;
width: 32px;
height: 32px;
}
.lyrics-tooltip-wrapper button.switch.disabled,
.lyrics-tooltip-wrapper button.switch[disabled],
#lyrics-plus-config-container button.switch.disabled,
#lyrics-plus-config-container button.switch[disabled] {
color: rgba(var(--spice-rgb-text), 0.3);
}
.lyrics-tooltip-wrapper button.switch.small,
#lyrics-plus-config-container button.switch.small {
width: 22px;
height: 22px;
padding: 3px;
}
.lyrics-tooltip-wrapper input,
#lyrics-plus-config-container input {
width: 100%;
margin-top: 10px;
padding: 0 5px;
height: 32px;
border: 0;
color: var(--spice-text);
background-color: initial;
border-bottom: 1px solid var(--spice-text);
}
.lyrics-tooltip-wrapper .col.action .adjust-value,
#lyrics-plus-config-container .col.action .adjust-value {
margin-inline-start: 12px;
min-width: 22px;
text-align: center;
}
.lyrics-tooltip-wrapper .col.action span,
#lyrics-plus-config-container .col.action span {
font-size: 14px;
opacity: 0.8;
}
.lyrics-tooltip-wrapper .col.action .btn,
#lyrics-plus-config-container .col.action .btn {
font-weight: 700;
background-color: transparent;
border-radius: 500px;
transition-duration: 33ms;
transition-property: background-color, border-color, color, box-shadow, filter, transform;
padding-inline: 15px;
border: 1px solid #727272;
color: var(--spice-text);
min-block-size: 32px;
cursor: pointer;
}
.lyrics-tooltip-wrapper .col.action .btn:hover,
#lyrics-plus-config-container .col.action .btn:hover {
transform: scale(1.04);
border-color: var(--spice-text);
}
.lyrics-tooltip-wrapper .col.action .btn:disabled,
#lyrics-plus-config-container .col.action .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.lyrics-tooltip-wrapper .col.action .main-dropDown-dropDown,
.lyrics-tooltip-wrapper .col.action input,
#lyrics-plus-config-container .col.action .main-dropDown-dropDown,
#lyrics-plus-config-container .col.action input {
width: 150px;
}
#lyrics-fullscreen-container {
position: fixed;
width: 100vw;
height: 100vh;
cursor: default;
left: 0;
top: 0;
}
#lyrics-fullscreen-container .lyrics-lyricsContainer-LyricsContainer {
height: 100vh;
margin-bottom: 0;
margin-top: 0;
overflow-y: auto;
}
#lyrics-fullscreen-container .lyrics-lyricsContainer-LyricsContainer::-webkit-scrollbar {
background-color: var(--lyrics-color-background);
}
.lyrics-lyricsContainer-LyricsContainer.fad-enabled {
height: 100vh;
margin-top: 0;
margin-bottom: 0;
overflow-y: scroll;
}
.lyrics-lyricsContainer-LyricsContainer.fad-enabled .lyrics-lyricsContainer-LyricsLine {
margin-left: 100px;
margin-right: 100px;
}
.lyrics-lyricsContainer-LyricsContainer.fad-enabled .lyrics-lyricsContainer-LyricsBackground,
.lyrics-lyricsContainer-LyricsContainer.fad-enabled .lyrics-lyricsContainer-Provider {
display: none;
}
.lyrics-lyricsContainer-LyricsContainer.fad-enabled .lyrics-lyricsContainer-SyncedLyricsPage {
width: 100%;
}
.lyrics-lyricsContainer-LyricsContainer.fad-enabled .lyrics-config-button-container {
opacity: 0;
transition: opacity 0.2s cubic-bezier(0, 0, 0.58, 1);
}
.lyrics-lyricsContainer-LyricsContainer.fad-enabled:hover .lyrics-config-button-container {
opacity: 1;
}
.lyrics-idling-indicator {
display: inline-block;
opacity: 1;
transition: opacity 0.2s cubic-bezier(0, 0, 0.58, 1);
}
.lyrics-idling-indicator-hidden {
opacity: 0;
}
.lyrics-idling-indicator__circle {
background-color: var(--lyrics-color-active);
border-radius: 50%;
display: inline-block;
opacity: 0.5;
margin-right: calc(var(--lyrics-font-size) / 4);
transform-origin: center;
transition-timing-function: linear;
transition-duration: var(--indicator-delay);
transition-property: transform, opacity;
height: var(--lyrics-font-size);
width: var(--lyrics-font-size);
transform: scale(0.5);
}
.lyrics-idling-indicator__circle.active {
opacity: 1;
transform: scale(0.7);
}
.lyrics-config-button-container {
-webkit-margin-end: 32px;
-webkit-box-pack: end;
pointer-events: none;
bottom: 32px;
display: flex;
justify-content: flex-end;
margin: -52px 0 0;
margin-inline-end: 32px;
position: sticky;
z-index: 2;
}
.lyrics-config-button-container > * {
pointer-events: auto;
}
@-webkit-keyframes spin {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
.lyrics-config-button {
align-items: center;
background-color: rgba(0, 0, 0, 0.5);
border: 0;
margin: 5px;
border-radius: 4px;
color: #eee;
cursor: pointer;
display: flex;
gap: 8px;
justify-content: center;
padding: 12px;
height: 40px;
width: 40px;
}
.lyrics-config-button-container .main-contextMenu-menu {
color: var(--spice-text);
padding: 12px 12px 6px;
}
.lyrics-lyricsContainer-UnsyncedLyricsPage .split {
display: flex;
}
.lyrics-lyricsContainer-UnsyncedLyricsPage .split > div {
flex: 50%;
}
.lyrics-lyricsContainer-UnsyncedLyricsPage .split > div > div:not(.lyrics-versionSelector) {
margin-left: 0;
margin-right: 0;
}
.split .lyrics-lyricsContainer-LyricsLine {
padding-left: 50px;
padding-right: 50px;
}
.split .lyrics-versionSelector {
margin-right: 50px;
margin-left: 50px;
}
.split .lyrics-versionSelector select {
width: 100%;
}
@media (min-width: 1024px) {
.split .lyrics-lyricsContainer-LyricsLine {
padding-left: 75px;
padding-right: 75px;
}
.split .lyrics-versionSelector {
margin-right: 75px;
margin-left: 75px;
}
}
@media (min-width: 1280px) {
.split .lyrics-lyricsContainer-LyricsLine {
padding-left: 100px;
padding-right: 100px;
}
.split .lyrics-versionSelector {
margin-right: 100px;
margin-left: 100px;
}
}

View file

@ -22,11 +22,11 @@ sidebar_config = 1
home_config = 1 home_config = 1
experimental_features = 1 experimental_features = 1
extensions = extensions =
custom_apps = marketplace|stats|library|better-local-files custom_apps = marketplace|stats|library|better-local-files|lyrics-plus
[Patch] [Patch]
; DO NOT CHANGE! ; DO NOT CHANGE!
[Backup] [Backup]
version = 1.2.37.701.ge66eb7bc version = 1.2.40.599.g606b7f29
with = 2.36.13 with = 2.36.13