diff --git a/CustomApps/lyrics-plus/OptionsMenu.js b/CustomApps/lyrics-plus/OptionsMenu.js
index 35e3cafe48..eaa678651b 100644
--- a/CustomApps/lyrics-plus/OptionsMenu.js
+++ b/CustomApps/lyrics-plus/OptionsMenu.js
@@ -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,
diff --git a/CustomApps/lyrics-plus/README.md b/CustomApps/lyrics-plus/README.md
index ffa47ce008..0a961866fb 100644
--- a/CustomApps/lyrics-plus/README.md
+++ b/CustomApps/lyrics-plus/README.md
@@ -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)
@@ -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:
@@ -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).
diff --git a/CustomApps/lyrics-plus/Translator.js b/CustomApps/lyrics-plus/Translator.js
new file mode 100644
index 0000000000..36c36815d0
--- /dev/null
+++ b/CustomApps/lyrics-plus/Translator.js
@@ -0,0 +1,70 @@
+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 dictPath = "https:/cdn.jsdelivr.net/npm/kuromoji@0.1.2/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
+ });
+ }
+}
diff --git a/CustomApps/lyrics-plus/Utils.js b/CustomApps/lyrics-plus/Utils.js
index de8775efbe..7666f927fe 100644
--- a/CustomApps/lyrics-plus/Utils.js
+++ b/CustomApps/lyrics-plus/Utils.js
@@ -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("");
+ const reactChildren = [];
+
+ reactChildren.push(rubyElems[0]);
+
+ for (let i = 1; i < rubyElems.length; i++) {
+ const kanji = rubyElems[i].split("")[1]);
+ }
+
+ return react.createElement("p1", null, reactChildren);
+ }
}
diff --git a/CustomApps/lyrics-plus/conversion.png b/CustomApps/lyrics-plus/conversion.png
new file mode 100644
index 0000000000..669124681e
Binary files /dev/null and b/CustomApps/lyrics-plus/conversion.png differ
diff --git a/CustomApps/lyrics-plus/index.js b/CustomApps/lyrics-plus/index.js
index 96fbb2269a..058823f4ff 100644
--- a/CustomApps/lyrics-plus/index.js
+++ b/CustomApps/lyrics-plus/index.js
@@ -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"),
@@ -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: {
@@ -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) {
@@ -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" });
@@ -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() {
@@ -500,6 +547,7 @@ class LyricsContainer extends react.Component {
}
}
+ const translatedLyrics = this.state[CONFIG.visual["translation-mode"]];
let activeItem;
if (mode !== -1) {
@@ -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
});
@@ -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",
@@ -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,
diff --git a/CustomApps/lyrics-plus/manifest.json b/CustomApps/lyrics-plus/manifest.json
index b06091de3c..4e9d8f037f 100644
--- a/CustomApps/lyrics-plus/manifest.json
+++ b/CustomApps/lyrics-plus/manifest.json
@@ -1,81 +1,82 @@
{
- "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": "Lyrics",
- "hu": "Dalszöveg"
- },
- "icon": "",
- "active-icon": "",
- "subfiles": [
- "ProviderNetease.js",
- "ProviderMusixmatch.js",
- "ProviderGenius.js",
- "Providers.js",
- "Pages.js",
- "OptionsMenu.js",
- "TabBar.js",
- "Utils.js",
- "Settings.js"
- ]
+ "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": "Lyrics",
+ "hu": "Dalszöveg"
+ },
+ "icon": "",
+ "active-icon": "",
+ "subfiles": [
+ "ProviderNetease.js",
+ "ProviderMusixmatch.js",
+ "ProviderGenius.js",
+ "Providers.js",
+ "Pages.js",
+ "OptionsMenu.js",
+ "TabBar.js",
+ "Utils.js",
+ "Settings.js",
+ "Translator.js"
+ ]
}
diff --git a/CustomApps/lyrics-plus/style.css b/CustomApps/lyrics-plus/style.css
index e2aa843f0c..22fdaee27c 100644
--- a/CustomApps/lyrics-plus/style.css
+++ b/CustomApps/lyrics-plus/style.css
@@ -589,10 +589,32 @@ button.switch.small {
z-index: 2;
}
+.lyrics-translation-spinner {
+ border: 2px solid #cdcdcd;
+ border-top: 3px solid rgba(255, 255, 255, 0);
+ border-radius: 50%;
+ width: 15px;
+ height: 15px;
+ margin-left: 5px;
+ margin-top: 5px;
+ display: inline-block;
+ -webkit-animation: spin 1s linear infinite;
+}
+
+@-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;