✨ feat(spicetify): add lyrics-plus custom app
This commit is contained in:
parent
4d9978ef7f
commit
50aeec9c41
22 changed files with 5020 additions and 2 deletions
326
.config/spicetify/CustomApps/lyrics-plus/OptionsMenu.js
Normal file
326
.config/spicetify/CustomApps/lyrics-plus/OptionsMenu.js
Normal 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"
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
739
.config/spicetify/CustomApps/lyrics-plus/Pages.js
Executable file
739
.config/spicetify/CustomApps/lyrics-plus/Pages.js
Executable 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"
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
48
.config/spicetify/CustomApps/lyrics-plus/PlaybarButton.js
Normal file
48
.config/spicetify/CustomApps/lyrics-plus/PlaybarButton.js
Normal 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();
|
||||
}
|
||||
})();
|
133
.config/spicetify/CustomApps/lyrics-plus/ProviderGenius.js
Normal file
133
.config/spicetify/CustomApps/lyrics-plus/ProviderGenius.js
Normal 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 };
|
||||
})();
|
53
.config/spicetify/CustomApps/lyrics-plus/ProviderLRCLIB.js
Normal file
53
.config/spicetify/CustomApps/lyrics-plus/ProviderLRCLIB.js
Normal 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 };
|
||||
})();
|
191
.config/spicetify/CustomApps/lyrics-plus/ProviderMusixmatch.js
Normal file
191
.config/spicetify/CustomApps/lyrics-plus/ProviderMusixmatch.js
Normal 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 };
|
||||
})();
|
205
.config/spicetify/CustomApps/lyrics-plus/ProviderNetease.js
Normal file
205
.config/spicetify/CustomApps/lyrics-plus/ProviderNetease.js
Normal 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 };
|
||||
})();
|
213
.config/spicetify/CustomApps/lyrics-plus/Providers.js
Normal file
213
.config/spicetify/CustomApps/lyrics-plus/Providers.js
Normal 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;
|
||||
}
|
||||
};
|
46
.config/spicetify/CustomApps/lyrics-plus/README.md
Normal file
46
.config/spicetify/CustomApps/lyrics-plus/README.md
Normal 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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
Choose between different option of displaying Japanese lyrics. (Furigana, Romaji, Hiragana, Katakana)
|
||||
|
||||

|
||||
|
||||
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).
|
657
.config/spicetify/CustomApps/lyrics-plus/Settings.js
Normal file
657
.config/spicetify/CustomApps/lyrics-plus/Settings.js
Normal 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
|
||||
});
|
||||
}
|
204
.config/spicetify/CustomApps/lyrics-plus/TabBar.js
Normal file
204
.config/spicetify/CustomApps/lyrics-plus/TabBar.js
Normal 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
|
||||
)
|
||||
);
|
||||
});
|
146
.config/spicetify/CustomApps/lyrics-plus/Translator.js
Normal file
146
.config/spicetify/CustomApps/lyrics-plus/Translator.js
Normal 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));
|
||||
}
|
||||
}
|
296
.config/spicetify/CustomApps/lyrics-plus/Utils.js
Normal file
296
.config/spicetify/CustomApps/lyrics-plus/Utils.js
Normal file
File diff suppressed because one or more lines are too long
BIN
.config/spicetify/CustomApps/lyrics-plus/conversion.png
Normal file
BIN
.config/spicetify/CustomApps/lyrics-plus/conversion.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 823 KiB |
BIN
.config/spicetify/CustomApps/lyrics-plus/genius.png
Normal file
BIN
.config/spicetify/CustomApps/lyrics-plus/genius.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 168 KiB |
963
.config/spicetify/CustomApps/lyrics-plus/index.js
Normal file
963
.config/spicetify/CustomApps/lyrics-plus/index.js
Normal 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;
|
||||
}
|
||||
}
|
BIN
.config/spicetify/CustomApps/lyrics-plus/kara.png
Normal file
BIN
.config/spicetify/CustomApps/lyrics-plus/kara.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 123 KiB |
BIN
.config/spicetify/CustomApps/lyrics-plus/lockin.png
Normal file
BIN
.config/spicetify/CustomApps/lyrics-plus/lockin.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.2 KiB |
84
.config/spicetify/CustomApps/lyrics-plus/manifest.json
Normal file
84
.config/spicetify/CustomApps/lyrics-plus/manifest.json
Normal 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"]
|
||||
}
|
BIN
.config/spicetify/CustomApps/lyrics-plus/search.png
Normal file
BIN
.config/spicetify/CustomApps/lyrics-plus/search.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 172 KiB |
714
.config/spicetify/CustomApps/lyrics-plus/style.css
Normal file
714
.config/spicetify/CustomApps/lyrics-plus/style.css
Normal 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;
|
||||
}
|
||||
}
|
|
@ -22,11 +22,11 @@ sidebar_config = 1
|
|||
home_config = 1
|
||||
experimental_features = 1
|
||||
extensions =
|
||||
custom_apps = marketplace|stats|library|better-local-files
|
||||
custom_apps = marketplace|stats|library|better-local-files|lyrics-plus
|
||||
|
||||
[Patch]
|
||||
|
||||
; DO NOT CHANGE!
|
||||
[Backup]
|
||||
version = 1.2.37.701.ge66eb7bc
|
||||
version = 1.2.40.599.g606b7f29
|
||||
with = 2.36.13
|
||||
|
|
Loading…
Add table
Reference in a new issue