Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(lyrics-plus): add japanese lyrics conversion tool #1990

Merged
merged 37 commits into from
Dec 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
d75977b
feat(lyricsplus): Added conversion tools for Japanese Lyrics (#1988)
41pha1 Oct 22, 2022
d9afcb2
feat(lyricsplus): Added conversion tools for Japanese Lyrics
41pha1 Oct 22, 2022
f95981a
Merge branch 'lyrics-converter' of https://github.com/41pha1/spicetif…
41pha1 Oct 22, 2022
74da324
feat: added conversion tools for japanese lyrics
41pha1 Oct 22, 2022
2caeb23
Merge branch 'lyrics-converter' of https://github.com/41pha1/spicetif…
41pha1 Oct 22, 2022
2042f54
fix: added dictionary files
41pha1 Oct 22, 2022
7642f4e
fix: added dictionary files
41pha1 Oct 22, 2022
9928723
fix: prettified dicts
41pha1 Oct 22, 2022
2e50943
feat: added lyric conversion description to readme
41pha1 Oct 22, 2022
6757de1
fix: added credit to the original kuroshiro repo
41pha1 Oct 22, 2022
6de3761
fix: capitalization in readme credits
41pha1 Oct 23, 2022
8fb0a2c
fix: capitalization and style in readme
41pha1 Oct 23, 2022
9f785e5
fix: load the correct lyrics for unsynced lyrics
41pha1 Oct 23, 2022
d581a0c
fix: fetch dicts via jsdeliver if not found
41pha1 Oct 23, 2022
fccc83f
fix: prettfied translator
41pha1 Oct 23, 2022
45de998
fix: properly assigning the japanese lyric variables to null
41pha1 Oct 23, 2022
76649eb
fix: conversion tools work for unsynced songs too now
41pha1 Oct 23, 2022
2c9d7b1
Merge branch 'lyrics-converter' of https://github.com/41pha1/spicetif…
41pha1 Oct 23, 2022
6190cb2
refactor: use nullish coalescing operator
rxri Oct 23, 2022
3373878
refactor: changing let to const in utils.js
41pha1 Oct 23, 2022
2b2e340
refactor: changing let to const in index.js translate
41pha1 Oct 23, 2022
a55b865
refactor: changing let to const for translated lyrics var
41pha1 Oct 23, 2022
fd715a7
refactor: changing let to const for conversion menu paramaters
41pha1 Oct 23, 2022
16c7c37
refactor: prettified files
41pha1 Oct 23, 2022
d28c271
refactor: use cont instead of let for clarity in util.js
41pha1 Oct 23, 2022
7e2b63f
Merge remote-tracking branch 'upstream/master' into lyrics-converter
41pha1 Oct 29, 2022
94098b4
merge: resolved merge conflicts from #1992
41pha1 Oct 29, 2022
ba86eee
feat: changed urls to load dictionaries from new origin
41pha1 Oct 30, 2022
392a6e9
Merge branch 'spicetify:master' into lyrics-converter
41pha1 Oct 30, 2022
46acc2d
fix: added a check if lyrics are japanese
41pha1 Nov 1, 2022
dfceb90
refactor: prettiefied lyrics-plus with correct config.
41pha1 Nov 1, 2022
2d67f8e
refactor: added uncompressed code into separate files
41pha1 Nov 5, 2022
a77b2df
Revert "refactor: added uncompressed code into separate files"
41pha1 Nov 5, 2022
a5bfbac
refactor: now loading kuroshiro from npm
41pha1 Dec 25, 2022
99a90ea
Merge remote-tracking branch 'origin/master' into lyrics-converter
41pha1 Dec 25, 2022
38a6eb8
fix: prevent applying the xmlhttp fix twice
41pha1 Dec 25, 2022
f6cf5f8
style: prettier
41pha1 Dec 25, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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