Skip to content

Commit

Permalink
feat(lyrics-plus): add japanese lyrics conversion tool (#1990)
Browse files Browse the repository at this point in the history
  • Loading branch information
41pha1 authored Dec 26, 2022
1 parent 40f87fc commit 8fb666f
Show file tree
Hide file tree
Showing 8 changed files with 340 additions and 87 deletions.
70 changes: 70 additions & 0 deletions CustomApps/lyrics-plus/OptionsMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,76 @@ const OptionsMenu = react.memo(({ options, onSelect, selected, defaultValue, bol
);
});

const TranslationMenu = react.memo(({ showTranslationButton, translatorLoaded }) => {
if (!showTranslationButton) return null;

return react.createElement(
Spicetify.ReactComponent.ContextMenu,
{
menu: react.createElement(
Spicetify.ReactComponent.Menu,
{},
react.createElement("h3", null, " Conversions"),
translatorLoaded
? react.createElement(OptionList, {
items: [
{
desc: "Mode",
key: "translation-mode",
type: ConfigSelection,
options: {
furigana: "Furigana",
romaji: "Romaji",
hiragana: "Hiragana",
katakana: "Katakana"
},
renderInline: true
},
{
desc: "Convert",
key: "translate",
type: ConfigSlider,
trigger: "click",
action: "toggle",
renderInline: true
}
],
onChange: (name, value) => {
CONFIG.visual[name] = value;
localStorage.setItem(`${APP_NAME}:visual:${name}`, value);
lyricContainerUpdate && lyricContainerUpdate();
}
})
: react.createElement(
"div",
null,
react.createElement("p1", null, "Loading"),
react.createElement("div", { class: "lyrics-translation-spinner" }, "")
)
),
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.ContextMenu,
Expand Down
18 changes: 12 additions & 6 deletions CustomApps/lyrics-plus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
### 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.

- Internal Spotify lyrics service.
- Netease: From Chinese developers and users. Provides karaoke and synced lyrics.
- Musixmatch: A company from Italy. Provided synced lyrics.
- Genius: Provide unsynced lyrics but with description/insight from artists themselve.

![kara](./kara.png)

Expand All @@ -22,6 +23,10 @@ Lyrics in Unsynced and Genius modes can be search and jump to. Hit Ctrl + Shift

![search](./search.png)

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

![conversion](./conversion.png)

Customise colors, change providers' priorities in config menu. Config menu locates in Profile Menu (top right button with your user name).

To install, run:
Expand All @@ -33,5 +38,6 @@ 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.
- 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).
70 changes: 70 additions & 0 deletions CustomApps/lyrics-plus/Translator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
const kuroshiroPath = "https://cdn.jsdelivr.net/npm/[email protected]/dist/kuroshiro.min.js";
const kuromojiPath = "https://cdn.jsdelivr.net/npm/[email protected]/dist/kuroshiro-analyzer-kuromoji.min.js";

const dictPath = "https:/cdn.jsdelivr.net/npm/[email protected]/dict";

class Translator {
constructor() {
this.includeExternal(kuroshiroPath);
this.includeExternal(kuromojiPath);

this.createKuroshiro();

this.finished = false;
}

includeExternal(url) {
var s = document.createElement("script");
s.setAttribute("type", "text/javascript");
s.setAttribute("src", url);
var nodes = document.getElementsByTagName("*");
var node = nodes[nodes.length - 1].parentNode;
node.appendChild(s);
}

/**
* 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 createKuroshiro() {
if (typeof Kuroshiro === "undefined" || typeof KuromojiAnalyzer === "undefined") {
//Waiting for JSDeliver to load Kuroshiro and Kuromoji
setTimeout(this.createKuroshiro.bind(this), 50);
return;
}

this.kuroshiro = new Kuroshiro.default();

this.applyKuromojiFix();

this.kuroshiro.init(new KuromojiAnalyzer({ dictPath: dictPath })).then(
function () {
this.finished = true;
}.bind(this)
);
}

async romajifyText(text, target = "romaji", mode = "spaced") {
if (!this.finished) {
setTimeout(this.romajifyText.bind(this), 100, text, target, mode);
return;
}

return this.kuroshiro.convert(text, {
to: target,
mode: mode
});
}
}
27 changes: 27 additions & 0 deletions CustomApps/lyrics-plus/Utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,31 @@ class Utils {
static capitalize(s) {
return s.replace(/^(\w)/, $1 => $1.toUpperCase());
}

static isJapanese(lyrics) {
for (let lyric of lyrics)
if (/[\u3000-\u303F]|[\u3040-\u309F]|[\u30A0-\u30FF]|[\uFF00-\uFFEF]|[\u4E00-\u9FAF]|[\u2605-\u2606]|[\u2190-\u2195]|\u203B/g.test(lyric.text))
return true;
return false;
}

static rubyTextToReact(s) {
const react = Spicetify.React;

const rubyElems = s.split("<ruby>");
const reactChildren = [];

reactChildren.push(rubyElems[0]);

for (let i = 1; i < rubyElems.length; i++) {
const kanji = rubyElems[i].split("<rp>")[0];
const furigana = rubyElems[i].split("<rt>")[1].split("</rt>")[0];

reactChildren.push(react.createElement("ruby", null, kanji, react.createElement("rt", null, furigana)));

reactChildren.push(rubyElems[i].split("</ruby>")[1]);
}

return react.createElement("p1", null, reactChildren);
}
}
Binary file added CustomApps/lyrics-plus/conversion.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
61 changes: 59 additions & 2 deletions CustomApps/lyrics-plus/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ const CONFIG = {
["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",
["translation-mode"]: localStorage.getItem("lyrics-plus:visual:translation-mode") || "furigana",
["translate"]: getConfig("lyrics-plus:visual:translate"),
["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"),
Expand Down Expand Up @@ -118,6 +120,10 @@ class LyricsContainer extends react.Component {
unsynced: null,
genius: null,
genius2: null,
romaji: null,
furigana: null,
hiragana: null,
katakana: null,
uri: "",
provider: "",
colors: {
Expand All @@ -142,6 +148,7 @@ class LyricsContainer extends react.Component {
this.fullscreenContainer.id = "lyrics-fullscreen-container";
this.mousetrap = new Spicetify.Mousetrap();
this.containerRef = react.createRef(null);
this.translator = new Translator();
}

infoFromTrack(track) {
Expand Down Expand Up @@ -220,6 +227,7 @@ class LyricsContainer extends react.Component {
}

async fetchLyrics(track, mode = -1) {
this.state.furigana = this.state.romaji = this.state.hirgana = this.state.katakana = null;
const info = this.infoFromTrack(track);
if (!info) {
this.setState({ error: "No track info" });
Expand All @@ -236,24 +244,63 @@ class LyricsContainer extends react.Component {
if (CACHE[info.uri]?.[CONFIG.modes[mode]]) {
this.resetDelay();
this.setState({ ...CACHE[info.uri] });
this.translateLyrics();
return;
}
} else {
if (CACHE[info.uri]) {
this.resetDelay();
this.setState({ ...CACHE[info.uri] });
this.translateLyrics();
return;
}
}

this.setState({ ...emptyState, isLoading: true });
const resp = await this.tryServices(info, mode);

// 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 });
}

this.translateLyrics();
}

async translateLyrics() {
if (!this.translator || !this.translator.finished) {
setTimeout(this.translateLyrics.bind(this), 100);
return;
}

const lyricsToTranslate = this.state.synced ?? this.state.unsynced;

if (!lyricsToTranslate || !Utils.isJapanese(lyricsToTranslate)) return;

let lyricText = "";
for (let lyric of lyricsToTranslate) lyricText += lyric.text + "\n";

[
["romaji", "spaced", "romaji"],
["hiragana", "furigana", "furigana"],
["hiragana", "normal", "hiragana"],
["katakana", "normal", "katakana"]
].map(params =>
this.translator.romajifyText(lyricText, params[0], params[1]).then(result => {
const translatedLines = result.split("\n");

this.state[params[2]] = [];

for (let i = 0; i < lyricsToTranslate.length; i++)
this.state[params[2]].push({
startTime: lyricsToTranslate[i].startTime || 0,
text: Utils.rubyTextToReact(translatedLines[i])
});
lyricContainerUpdate && lyricContainerUpdate();
})
);
}

resetDelay() {
Expand Down Expand Up @@ -500,6 +547,7 @@ class LyricsContainer extends react.Component {
}
}

const translatedLyrics = this.state[CONFIG.visual["translation-mode"]];
let activeItem;

if (mode !== -1) {
Expand All @@ -514,14 +562,14 @@ class LyricsContainer extends react.Component {
} else if (mode === SYNCED && this.state.synced) {
activeItem = react.createElement(CONFIG.visual["synced-compact"] ? SyncedLyricsPage : SyncedExpandedLyricsPage, {
trackUri: this.state.uri,
lyrics: this.state.synced,
lyrics: CONFIG.visual["translate"] && translatedLyrics ? translatedLyrics : this.state.synced,
provider: this.state.provider,
copyright: this.state.copyright
});
} else if (mode === UNSYNCED && this.state.unsynced) {
activeItem = react.createElement(UnsyncedLyricsPage, {
trackUri: this.state.uri,
lyrics: this.state.unsynced,
lyrics: CONFIG.visual["translate"] && translatedLyrics ? translatedLyrics : this.state.unsynced,
provider: this.state.provider,
copyright: this.state.copyright
});
Expand Down Expand Up @@ -559,6 +607,11 @@ class LyricsContainer extends react.Component {
}

this.state.mode = mode;
const showTranslationButton =
(this.state.synced || this.state.unsynced) &&
Utils.isJapanese(this.state.synced || this.state.unsynced) &&
(mode == SYNCED || mode == UNSYNCED);
const translatorLoaded = this.translator.finished;

const out = react.createElement(
"div",
Expand All @@ -579,6 +632,10 @@ class LyricsContainer extends react.Component {
{
className: "lyrics-config-button-container"
},
react.createElement(TranslationMenu, {
showTranslationButton,
translatorLoaded
}),
react.createElement(AdjustmentsMenu, { mode }),
react.createElement(
Spicetify.ReactComponent.TooltipWrapper,
Expand Down
Loading

0 comments on commit 8fb666f

Please sign in to comment.