mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-13 03:11:46 +00:00
feat(synced-lyrics): romanization (#2790)
* feat(synced-lyrics): init romanization! * remove debug logs and add TODO * feat(synced-lyrics/romanization): Mandarin! * feat(synced-lyrics/romanization): improve japanese detection * feat(synced-lyrics/romanization): Korean! * qol(synced-lyrics/romanization): canonicalize punctuation and symbols * feat(synced-lyrics/romanization): handle japanese+korean and korean+chinese lyrics * revert formatting on electron.vite.config.mts * feat(synced-lyrics/romanization): romanize plain lyrics * apply fix by @kimjammer * fix lockfile due to rebase * feat(synced-lyrics): improve lyric processing and formatting; * feat(synced-lyrics/romanization): add option to enable/disable romanization * chore: move default value for --lyrics-duration to the declaration * update lockfile * fix: improvement 1. improved language detection logic 2. changed code to work in the renderer process * fix: fix regression (canonicalize) --------- Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
@ -20,7 +20,8 @@ export default createPlugin({
|
||||
showTimeCodes: false,
|
||||
defaultTextString: '♪',
|
||||
lineEffect: 'fancy',
|
||||
} satisfies SyncedLyricsPluginConfig,
|
||||
romanization: true,
|
||||
} satisfies SyncedLyricsPluginConfig as SyncedLyricsPluginConfig,
|
||||
|
||||
menu,
|
||||
renderer,
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { MenuItemConstructorOptions } from 'electron';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import type { MenuContext } from '@/types/contexts';
|
||||
import type { SyncedLyricsPluginConfig } from './types';
|
||||
|
||||
@ -136,6 +135,17 @@ export const menu = async (
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('plugins.synced-lyrics.menu.romanization.label'),
|
||||
toolTip: t('plugins.synced-lyrics.menu.romanization.tooltip'),
|
||||
type: 'checkbox',
|
||||
checked: config.romanization,
|
||||
click(item) {
|
||||
ctx.setConfig({
|
||||
romanization: item.checked,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('plugins.synced-lyrics.menu.show-time-codes.label'),
|
||||
toolTip: t('plugins.synced-lyrics.menu.show-time-codes.tooltip'),
|
||||
|
||||
@ -30,15 +30,16 @@ export class LyricsGenius implements LyricProvider {
|
||||
title: titleA,
|
||||
primary_artist: { name: artistA },
|
||||
},
|
||||
},
|
||||
{
|
||||
}, {
|
||||
result: {
|
||||
title: titleB,
|
||||
primary_artist: { name: artistB },
|
||||
},
|
||||
}) => {
|
||||
const pointsA = (titleA === title ? 1 : 0) + (artistA.includes(artist) ? 1 : 0);
|
||||
const pointsB = (titleB === title ? 1 : 0) + (artistB.includes(artist) ? 1 : 0);
|
||||
const pointsA = (titleA === title ? 1 : 0) +
|
||||
(artistA.includes(artist) ? 1 : 0);
|
||||
const pointsB = (titleB === title ? 1 : 0) +
|
||||
(artistB.includes(artist) ? 1 : 0);
|
||||
|
||||
return pointsB - pointsA;
|
||||
},
|
||||
@ -51,14 +52,21 @@ export class LyricsGenius implements LyricProvider {
|
||||
|
||||
const { result: { path } } = closestHit;
|
||||
|
||||
const html = await fetch(`${this.baseUrl}${path}`).then((res) => res.text());
|
||||
const html = await fetch(`${this.baseUrl}${path}`).then((res) =>
|
||||
res.text()
|
||||
);
|
||||
const doc = this.domParser.parseFromString(html, 'text/html');
|
||||
|
||||
const preloadedStateScript = Array.prototype.find.call(doc.querySelectorAll('script'), (script: HTMLScriptElement) => {
|
||||
return script.textContent?.includes('window.__PRELOADED_STATE__');
|
||||
}) as HTMLScriptElement;
|
||||
const preloadedStateScript = Array.prototype.find.call(
|
||||
doc.querySelectorAll('script'),
|
||||
(script: HTMLScriptElement) => {
|
||||
return script.textContent?.includes('window.__PRELOADED_STATE__');
|
||||
},
|
||||
) as HTMLScriptElement;
|
||||
|
||||
const preloadedState = preloadedStateScript.textContent?.match(preloadedStateRegex)?.[1]?.replace(/\\"/g, '"');
|
||||
const preloadedState = preloadedStateScript.textContent?.match(
|
||||
preloadedStateRegex,
|
||||
)?.[1]?.replace(/\\"/g, '"');
|
||||
|
||||
const lyricsHtml = preloadedState?.match(preloadHtmlRegex)?.[1]
|
||||
?.replace(/\\\//g, '/')
|
||||
@ -67,12 +75,19 @@ export class LyricsGenius implements LyricProvider {
|
||||
?.replace(/\\'/g, "'")
|
||||
?.replace(/\\"/g, '"');
|
||||
|
||||
if (!lyricsHtml) throw new Error('Failed to extract lyrics from preloaded state.');
|
||||
const hasUnreleasedPlaceholder = preloadedState &&
|
||||
/lyricsPlaceholderReason.{1,5}unreleased/.test(preloadedState);
|
||||
if (!lyricsHtml) {
|
||||
if (hasUnreleasedPlaceholder) return null;
|
||||
throw new Error('Failed to extract lyrics from preloaded state.');
|
||||
}
|
||||
|
||||
const lyricsDoc = this.domParser.parseFromString(lyricsHtml, 'text/html');
|
||||
const lyrics = lyricsDoc.body.innerText;
|
||||
|
||||
if (lyrics.trim().toLowerCase().replace(/[[\]]/g, '') === 'instrumental') return null;
|
||||
if (lyrics.trim().toLowerCase().replace(/[[\]]/g, '') === 'instrumental') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: closestHit.result.title,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { createSignal, For, Match, Show, Switch } from 'solid-js';
|
||||
import { createEffect, createSignal, For, Match, Show, Switch } from 'solid-js';
|
||||
|
||||
import { SyncedLine } from './SyncedLine';
|
||||
|
||||
@ -6,6 +6,7 @@ import { ErrorDisplay } from './ErrorDisplay';
|
||||
import { LoadingKaomoji } from './LoadingKaomoji';
|
||||
import { PlainLyrics } from './PlainLyrics';
|
||||
|
||||
import { hasJapaneseInString, hasKoreanInString } from '../utils';
|
||||
import { currentLyrics, lyricsStore } from '../../providers';
|
||||
|
||||
export const [debugInfo, setDebugInfo] = createSignal<string>();
|
||||
@ -13,6 +14,20 @@ export const [currentTime, setCurrentTime] = createSignal<number>(-1);
|
||||
|
||||
// prettier-ignore
|
||||
export const LyricsContainer = () => {
|
||||
const [hasJapanese, setHasJapanese] = createSignal<boolean>(false);
|
||||
const [hasKorean, setHasKorean] = createSignal<boolean>(false);
|
||||
|
||||
createEffect(() => {
|
||||
const data = currentLyrics()?.data;
|
||||
if (data) {
|
||||
setHasKorean(hasKoreanInString(data));
|
||||
setHasJapanese(hasJapaneseInString(data));
|
||||
} else {
|
||||
setHasKorean(false);
|
||||
setHasJapanese(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="lyric-container">
|
||||
<Switch>
|
||||
@ -42,12 +57,12 @@ export const LyricsContainer = () => {
|
||||
<Switch>
|
||||
<Match when={currentLyrics().data?.lines}>
|
||||
<For each={currentLyrics().data?.lines}>
|
||||
{(item) => <SyncedLine line={item} />}
|
||||
{(item) => <SyncedLine line={item} hasJapanese={hasJapanese()} hasKorean={hasKorean()} />}
|
||||
</For>
|
||||
</Match>
|
||||
|
||||
<Match when={currentLyrics().data?.lyrics}>
|
||||
<PlainLyrics lyrics={currentLyrics().data?.lyrics!} />
|
||||
<PlainLyrics lyrics={currentLyrics().data!.lyrics!} hasJapanese={hasJapanese()} hasKorean={hasKorean()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
@ -1,28 +1,91 @@
|
||||
import { createMemo, For } from 'solid-js';
|
||||
import { createEffect, createMemo, createSignal, For, Show } from 'solid-js';
|
||||
|
||||
import {
|
||||
canonicalize,
|
||||
romanizeChinese,
|
||||
romanizeHangul,
|
||||
romanizeJapanese,
|
||||
romanizeJapaneseOrHangul,
|
||||
simplifyUnicode,
|
||||
} from '../utils';
|
||||
import { config } from '../renderer';
|
||||
|
||||
interface PlainLyricsProps {
|
||||
lyrics: string;
|
||||
hasJapanese: boolean;
|
||||
hasKorean: boolean;
|
||||
}
|
||||
|
||||
export const PlainLyrics = (props: PlainLyricsProps) => {
|
||||
const lines = createMemo(() => props.lyrics.split('\n'));
|
||||
const lines = props.lyrics.split('\n').filter((line) => line.trim());
|
||||
const [romanizedLines, setRomanizedLines] = createSignal<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
const combinedLines = createMemo(() => {
|
||||
const out = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
out.push([lines[i], romanizedLines()[i]]);
|
||||
}
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
createEffect(async () => {
|
||||
if (!config()?.romanization) return;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let romanized: string;
|
||||
|
||||
if (props.hasJapanese) {
|
||||
if (props.hasKorean)
|
||||
romanized = await romanizeJapaneseOrHangul(lines[i]);
|
||||
else romanized = await romanizeJapanese(lines[i]);
|
||||
} else if (props.hasKorean) romanized = romanizeHangul(lines[i]);
|
||||
else romanized = romanizeChinese(lines[i]);
|
||||
|
||||
setRomanizedLines((prev) => ({
|
||||
...prev,
|
||||
[i]: canonicalize(romanized),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="plain-lyrics">
|
||||
<For each={lines()}>
|
||||
{(line) => {
|
||||
if (line.trim() === '') {
|
||||
return <br />;
|
||||
} else {
|
||||
return (
|
||||
<For each={combinedLines()}>
|
||||
{([line, romanized]) => {
|
||||
return (
|
||||
<div
|
||||
class={`${
|
||||
line.match(/^\[.+\]$/s) ? 'lrc-header' : ''
|
||||
} text-lyrics description ytmusic-description-shelf-renderer`}
|
||||
style={{
|
||||
'display': 'flex',
|
||||
'flex-direction': 'column',
|
||||
}}
|
||||
>
|
||||
<yt-formatted-string
|
||||
class="text-lyrics description ytmusic-description-shelf-renderer"
|
||||
text={{
|
||||
runs: [{ text: line }],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
<Show
|
||||
when={
|
||||
config()?.romanization &&
|
||||
simplifyUnicode(line) !== simplifyUnicode(romanized)
|
||||
}
|
||||
>
|
||||
<yt-formatted-string
|
||||
class="romaji"
|
||||
text={{
|
||||
runs: [{ text: romanized }],
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
@ -1,22 +1,33 @@
|
||||
import { createEffect, createMemo, For } from 'solid-js';
|
||||
import { createEffect, createMemo, For, Show, createSignal } from 'solid-js';
|
||||
|
||||
import { currentTime } from './LyricsContainer';
|
||||
|
||||
import { config } from '../renderer';
|
||||
import { _ytAPI } from '..';
|
||||
|
||||
import {
|
||||
canonicalize,
|
||||
romanizeChinese,
|
||||
romanizeHangul,
|
||||
romanizeJapanese,
|
||||
romanizeJapaneseOrHangul,
|
||||
simplifyUnicode,
|
||||
} from '../utils';
|
||||
|
||||
import type { LineLyrics } from '../../types';
|
||||
|
||||
interface SyncedLineProps {
|
||||
line: LineLyrics;
|
||||
hasJapanese: boolean;
|
||||
hasKorean: boolean;
|
||||
}
|
||||
|
||||
export const SyncedLine = ({ line }: SyncedLineProps) => {
|
||||
export const SyncedLine = (props: SyncedLineProps) => {
|
||||
const status = createMemo(() => {
|
||||
const current = currentTime();
|
||||
|
||||
if (line.timeInMs >= current) return 'upcoming';
|
||||
if (current - line.timeInMs >= line.duration) return 'previous';
|
||||
if (props.line.timeInMs >= current) return 'upcoming';
|
||||
if (current - props.line.timeInMs >= props.line.duration) return 'previous';
|
||||
return 'current';
|
||||
});
|
||||
|
||||
@ -28,8 +39,28 @@ export const SyncedLine = ({ line }: SyncedLineProps) => {
|
||||
});
|
||||
|
||||
const text = createMemo(() => {
|
||||
if (line.text.trim()) return line.text;
|
||||
return config()?.defaultTextString ?? '';
|
||||
if (!props.line.text.trim()) {
|
||||
return config()?.defaultTextString ?? '';
|
||||
}
|
||||
|
||||
return props.line.text;
|
||||
});
|
||||
|
||||
const [romanization, setRomanization] = createSignal('');
|
||||
|
||||
createEffect(async () => {
|
||||
if (!config()?.romanization) return;
|
||||
|
||||
const input = canonicalize(text());
|
||||
|
||||
let result: string;
|
||||
if (props.hasJapanese) {
|
||||
if (props.hasKorean) result = await romanizeJapaneseOrHangul(input);
|
||||
else result = await romanizeJapanese(input);
|
||||
} else if (props.hasKorean) result = romanizeHangul(input);
|
||||
else result = romanizeChinese(input);
|
||||
|
||||
setRomanization(canonicalize(result));
|
||||
});
|
||||
|
||||
if (!text()) {
|
||||
@ -47,35 +78,79 @@ export const SyncedLine = ({ line }: SyncedLineProps) => {
|
||||
ref={ref}
|
||||
class={`synced-line ${status()}`}
|
||||
onClick={() => {
|
||||
_ytAPI?.seekTo(line.timeInMs / 1000);
|
||||
_ytAPI?.seekTo(props.line.timeInMs / 1000);
|
||||
}}
|
||||
>
|
||||
<div dir="auto" class="text-lyrics description ytmusic-description-shelf-renderer">
|
||||
<div dir="auto" class="description ytmusic-description-shelf-renderer">
|
||||
<yt-formatted-string
|
||||
text={{
|
||||
runs: [{ text: config()?.showTimeCodes ? `[${line.time}] ` : '' }],
|
||||
runs: [
|
||||
{ text: config()?.showTimeCodes ? `[${props.line.time}] ` : '' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
<For each={text().split(' ')}>
|
||||
{(word, index) => {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
'transition-delay': `${index() * 0.05}s`,
|
||||
'animation-delay': `${index() * 0.05}s`,
|
||||
'--lyrics-duration:': `${line.duration / 1000}s;`,
|
||||
}}
|
||||
>
|
||||
<yt-formatted-string
|
||||
text={{
|
||||
runs: [{ text: `${word} ` }],
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="text-lyrics"
|
||||
ref={(div: HTMLDivElement) => {
|
||||
// TODO: Investigate the animation, even though the duration is properly set, all lines have the same animation duration
|
||||
div.style.setProperty(
|
||||
'--lyrics-duration',
|
||||
`${props.line.duration / 1000}s`,
|
||||
'important',
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
style={{ 'display': 'flex', 'flex-direction': 'column' }}
|
||||
>
|
||||
<span>
|
||||
<For each={text().split(' ')}>
|
||||
{(word, index) => {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
'transition-delay': `${index() * 0.05}s`,
|
||||
'animation-delay': `${index() * 0.05}s`,
|
||||
}}
|
||||
>
|
||||
<yt-formatted-string
|
||||
text={{
|
||||
runs: [{ text: `${word} ` }],
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</span>
|
||||
|
||||
<Show
|
||||
when={
|
||||
config()?.romanization &&
|
||||
simplifyUnicode(text()) !== simplifyUnicode(romanization())
|
||||
}
|
||||
>
|
||||
<span class="romaji">
|
||||
<For each={romanization().split(' ')}>
|
||||
{(word, index) => {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
'transition-delay': `${index() * 0.05}s`,
|
||||
'animation-delay': `${index() * 0.05}s`,
|
||||
}}
|
||||
>
|
||||
<yt-formatted-string
|
||||
text={{
|
||||
runs: [{ text: `${word} ` }],
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,8 +1,20 @@
|
||||
import { render } from 'solid-js/web';
|
||||
|
||||
import KuromojiAnalyzer from 'kuroshiro-analyzer-kuromoji';
|
||||
import Kuroshiro from 'kuroshiro';
|
||||
|
||||
import { romanize as esHangulRomanize } from 'es-hangul';
|
||||
import hanja from 'hanja';
|
||||
|
||||
import pinyin from 'pinyin/esm/pinyin';
|
||||
|
||||
import { lazy } from 'lazy-var';
|
||||
|
||||
import { waitForElement } from '@/utils/wait-for-element';
|
||||
import { LyricsRenderer, setIsVisible } from './renderer';
|
||||
|
||||
import type { LyricResult } from '@/plugins/synced-lyrics/types';
|
||||
|
||||
export const selectors = {
|
||||
head: '#tabsContent > .tab-header:nth-of-type(2)',
|
||||
body: {
|
||||
@ -33,3 +45,148 @@ export const tabStates: Record<string, () => void> = {
|
||||
setIsVisible(false);
|
||||
},
|
||||
};
|
||||
|
||||
export const canonicalize = (text: string) => {
|
||||
return (
|
||||
text
|
||||
// `hi there` => `hi there`
|
||||
.replaceAll(/\s+/g, ' ')
|
||||
|
||||
// `( a )` => `(a)`
|
||||
.replaceAll(/([([]) ([^ ])/g, (_, symbol, a) => `${symbol}${a}`)
|
||||
.replaceAll(/([^ ]) ([)\]])/g, (_, a, symbol) => `${a}${symbol}`)
|
||||
|
||||
// `can ' t` => `can't`
|
||||
.replaceAll(
|
||||
/([Ii]) (') ([^ ])|(n) (') (t)(?= |$)|(t) (') (s)|([^ ]) (') (re)|([^ ]) (') (ve)|([^ ]) (-) ([^ ])/g,
|
||||
(m, ...groups) => {
|
||||
for (let i = 0; i < groups.length; i += 3) {
|
||||
if (groups[i]) {
|
||||
return groups.slice(i, i + 3).join('');
|
||||
}
|
||||
}
|
||||
|
||||
return m;
|
||||
},
|
||||
)
|
||||
// `Stayin ' still` => `Stayin' still`
|
||||
.replaceAll(/in ' ([^ ])/g, (_, char) => `in' ${char}`)
|
||||
.replaceAll("in ',", "in',")
|
||||
|
||||
.replaceAll(", ' cause", ", 'cause")
|
||||
|
||||
// `hi , there` => `hi, there`
|
||||
.replaceAll(/([^ ]) ([.,!?])/g, (_, a, symbol) => `${a}${symbol}`)
|
||||
|
||||
// `hi " there "` => `hi "there"`
|
||||
.replaceAll(
|
||||
/"([^"]+)"/g,
|
||||
(_, content) =>
|
||||
`"${typeof content === 'string' ? content.trim() : content}"`,
|
||||
)
|
||||
.trim()
|
||||
);
|
||||
};
|
||||
|
||||
export const simplifyUnicode = (text?: string) =>
|
||||
text
|
||||
? text
|
||||
.replaceAll(/\u0020|\u00A0|[\u2000-\u200A]|\u202F|\u205F|\u3000/g, ' ')
|
||||
.trim()
|
||||
: text;
|
||||
|
||||
// Japanese Shinjitai
|
||||
const shinjitai = [
|
||||
20055, 20081, 20120, 20124, 20175, 26469, 20341, 20206, 20253, 20605, 20385,
|
||||
20537, 20816, 20001, 20869, 23500, 28092, 20956, 21104, 21091, 21092, 21172,
|
||||
21234, 21169, 21223, 21306, 24059, 21363, 21442, 21782, 21336, 22107, 21427,
|
||||
22065, 22287, 22269, 22258, 20870, 22259, 22243, 37326, 23597, 22679, 22549,
|
||||
22311, 22593, 22730, 22732, 22766, 22769, 23551, 22885, 22888, 23330, 23398,
|
||||
23517, 23455, 20889, 23515, 23453, 23558, 23554, 23550, 23626, 23631, 23646,
|
||||
23792, 23777, 23798, 23731, 24012, 24035, 24111, 24182, 24259, 24195, 24193,
|
||||
24382, 24357, 24367, 24452, 24467, 24500, 24499, 24658, 24693, 24746, 24745,
|
||||
24910, 24808, 24540, 25040, 24651, 25126, 25135, 25144, 25147, 25173, 25244,
|
||||
25309, 25375, 25407, 25522, 25531, 25594, 25436, 25246, 25731, 25285, 25312,
|
||||
25369, 25313, 25666, 25785, 21454, 21177, 21465, 21189, 25968, 26029, 26179,
|
||||
26217, 26172, 26278, 26241, 26365, 20250, 26465, 26719, 26628, 27097, 27010,
|
||||
27005, 27004, 26530, 27096, 27178, 26727, 26908, 26716, 27177, 27431, 27475,
|
||||
27497, 27508, 24112, 27531, 27579, 27572, 27598, 27671, 28169, 28057, 27972,
|
||||
27973, 28167, 28179, 28201, 28382, 28288, 28300, 28508, 28171, 27810, 28287,
|
||||
28168, 27996, 27818, 28381, 28716, 28286, 28948, 28783, 28988, 21942, 28809,
|
||||
20105, 28858, 29344, 29366, 29421, 22888, 29420, 29471, 29539, 29486, 24321,
|
||||
29942, 30011, 24403, 30067, 30185, 30196, 30330, 26479, 30423, 23613, 30495,
|
||||
30740, 30741, 30783, 31192, 31108, 31109, 31036, 31074, 31095, 31216, 31282,
|
||||
38964, 31298, 31311, 31331, 31363, 20006, 31883, 31992, 32076, 32209, 32210,
|
||||
32257, 30476, 32294, 32207, 32333, 32260, 32117, 32331, 32153, 32154, 32330,
|
||||
27424, 32566, 22768, 32884, 31899, 33075, 32966, 33235, 21488, 19982, 26087,
|
||||
33398, 33624, 33550, 33804, 19975, 33931, 22290, 34219, 34101, 33464, 34220,
|
||||
33446, 20966, 34394, 21495, 34509, 34411, 34635, 34453, 34542, 34907, 35013,
|
||||
35090, 35226, 35239, 35251, 35302, 35617, 35388, 35379, 35465, 35501, 22793,
|
||||
35698, 35715, 35914, 33398, 20104, 24336, 22770, 38972, 36059, 36341, 36527,
|
||||
36605, 36620, 36578, 24321, 36766, 24321, 36965, 36883, 36933, 36794, 37070,
|
||||
37111, 37204, 21307, 37284, 37271, 37304, 37320, 37682, 37549, 37676, 37806,
|
||||
37444, 37619, 37489, 38306, 38501, 38543, 38522, 38560, 21452, 38609, 35207,
|
||||
38666, 38745, 39003, 38997, 32763, 20313, 39173, 39366, 39442, 39366, 39443,
|
||||
39365, 39620, 20307, 39658, 38360, 40335, 40206, 40568, 22633, 40614, 40633,
|
||||
40634, 40644, 40658, 40665, 28857, 20826, 25993, 25998, 27503, 40802, 31452,
|
||||
20096,
|
||||
].map((codePoint) => String.fromCodePoint(codePoint));
|
||||
const shinjitaiRegex = new RegExp(`[${shinjitai.join('')}]`);
|
||||
|
||||
const kuroshiro = lazy(async () => {
|
||||
const _kuroshiro = new Kuroshiro();
|
||||
await _kuroshiro.init(
|
||||
new KuromojiAnalyzer({
|
||||
dictPath: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/',
|
||||
}),
|
||||
);
|
||||
return _kuroshiro;
|
||||
});
|
||||
|
||||
const hasJapanese = (lines: string[]) =>
|
||||
lines.some(
|
||||
(line) => Kuroshiro.Util.hasKana(line) || shinjitaiRegex.test(line),
|
||||
);
|
||||
|
||||
// tests for Hangul characters, sufficient for our use case
|
||||
const hasKorean = (lines: string[]) =>
|
||||
lines.some((line) => /[ㄱ-ㅎㅏ-ㅣ가-힣]+/.test(line));
|
||||
|
||||
export const hasJapaneseInString = (lyric: LyricResult) => {
|
||||
if (!lyric || (!lyric.lines && !lyric.lyrics)) return false;
|
||||
const lines = Array.isArray(lyric.lines)
|
||||
? lyric.lines.map(({ text }) => text)
|
||||
: lyric.lyrics!.split('\n');
|
||||
return hasJapanese(lines);
|
||||
};
|
||||
|
||||
export const hasKoreanInString = (lyric: LyricResult) => {
|
||||
if (!lyric || (!lyric.lines && !lyric.lyrics)) return false;
|
||||
|
||||
const lines = Array.isArray(lyric.lines)
|
||||
? lyric.lines.map(({ text }) => text)
|
||||
: lyric.lyrics!.split('\n');
|
||||
|
||||
return hasKorean(lines);
|
||||
};
|
||||
|
||||
export const romanizeJapanese = async (line: string) =>
|
||||
(await kuroshiro.get()).convert(line, {
|
||||
to: 'romaji',
|
||||
mode: 'spaced',
|
||||
});
|
||||
|
||||
export const romanizeHangul = (line: string) =>
|
||||
esHangulRomanize(hanja.translate(line, 'SUBSTITUTION'));
|
||||
|
||||
export const romanizeJapaneseOrHangul = async (line: string) =>
|
||||
romanizeHangul(await romanizeJapanese(line));
|
||||
|
||||
export const romanizeChinese = (line: string) =>
|
||||
pinyin(line, {
|
||||
heteronym: true,
|
||||
segment: true,
|
||||
group: true,
|
||||
})
|
||||
.flat()
|
||||
.join(' ');
|
||||
|
||||
@ -8,6 +8,17 @@
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* Hide the scrollbar in the lyrics-tab */
|
||||
#tab-renderer[page-type='MUSIC_PAGE_TYPE_TRACK_LYRICS'] {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@property --lyrics-duration {
|
||||
syntax: '<time>';
|
||||
inherits: false;
|
||||
initial-value: 2s;
|
||||
}
|
||||
|
||||
/* Variables are overridden by selected line effect */
|
||||
:root {
|
||||
/* Layout */
|
||||
@ -15,8 +26,9 @@
|
||||
--lyrics-padding: 0;
|
||||
|
||||
/* Typography */
|
||||
--lyrics-font-family: Satoshi, Avenir, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
Open Sans, Helvetica Neue, sans-serif;
|
||||
--lyrics-font-family: Satoshi, Avenir, -apple-system, BlinkMacSystemFont,
|
||||
Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue,
|
||||
sans-serif;
|
||||
--lyrics-font-size: clamp(1.4rem, 1.1vmax, 3rem);
|
||||
--lyrics-line-height: var(--ytmusic-body-line-height);
|
||||
--lyrics-width: 100%;
|
||||
@ -33,12 +45,15 @@
|
||||
--lyrics-active-scale: 1;
|
||||
--lyrics-active-offset: 0;
|
||||
|
||||
--lyrics-duration: 2s;
|
||||
|
||||
/* Animations */
|
||||
--lyrics-animations: lyrics-glow var(--lyrics-glow-duration) forwards, lyrics-wobble var(--lyrics-wobble-duration) forwards;
|
||||
--lyrics-animations: lyrics-glow var(--lyrics-glow-duration) forwards,
|
||||
lyrics-wobble var(--lyrics-wobble-duration) forwards;
|
||||
--lyrics-scale-duration: 0.166s;
|
||||
--lyrics-opacity-transition: 0.33s;
|
||||
--lyrics-glow-duration: var(--lyrics-duration, 2s);
|
||||
--lyrics-wobble-duration: calc(var(--lyrics-duration, 2s) / 2);
|
||||
--lyrics-glow-duration: var(--lyrics-duration);
|
||||
--lyrics-wobble-duration: calc(var(--lyrics-duration) / 2);
|
||||
|
||||
/* Colors */
|
||||
--glow-color: rgba(255, 255, 255, 0.5);
|
||||
@ -56,9 +71,29 @@
|
||||
.synced-line {
|
||||
width: var(--lyrics-width, 100%);
|
||||
|
||||
& > .text-lyrics {
|
||||
& .text-lyrics {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
& .text-lyrics > .romaji {
|
||||
color: var(--ytmusic-text-secondary) !important;
|
||||
font-size: calc(var(--lyrics-font-size) * 0.7) !important;
|
||||
font-style: italic !important;
|
||||
}
|
||||
}
|
||||
|
||||
.plain-lyrics .romaji {
|
||||
color: var(--ytmusic-text-secondary) !important;
|
||||
font-size: calc(var(--lyrics-font-size) * 0.7) !important;
|
||||
font-style: italic !important;
|
||||
}
|
||||
|
||||
.plain-lyrics .lrc-header {
|
||||
color: var(--ytmusic-color-grey5) !important;
|
||||
scale: 0.9;
|
||||
height: fit-content;
|
||||
padding: 0;
|
||||
padding-block: 0.2em;
|
||||
}
|
||||
|
||||
.synced-lyrics {
|
||||
@ -83,9 +118,7 @@
|
||||
padding-bottom: var(--lyrics-padding);
|
||||
scale: var(--lyrics-inactive-scale);
|
||||
translate: var(--lyrics-inactive-offset);
|
||||
transition:
|
||||
scale var(--lyrics-scale-duration),
|
||||
translate 0.3s ease-in-out;
|
||||
transition: scale var(--lyrics-scale-duration), translate 0.3s ease-in-out;
|
||||
|
||||
display: block;
|
||||
text-align: left;
|
||||
@ -93,30 +126,24 @@
|
||||
transform-origin: 0 50%;
|
||||
}
|
||||
|
||||
.text-lyrics > span {
|
||||
.text-lyrics > span > span {
|
||||
display: inline-block;
|
||||
white-space: pre-wrap;
|
||||
opacity: var(--lyrics-inactive-opacity);
|
||||
transition: opacity var(--lyrics-opacity-transition);
|
||||
}
|
||||
|
||||
.previous > .text-lyrics {
|
||||
}
|
||||
|
||||
.current > .text-lyrics {
|
||||
.current .text-lyrics {
|
||||
font-weight: var(--lyrics-active-font-weight) !important;
|
||||
scale: var(--lyrics-active-scale);
|
||||
translate: var(--lyrics-active-offset);
|
||||
}
|
||||
|
||||
.current > .text-lyrics > span {
|
||||
.current .text-lyrics > span > span {
|
||||
opacity: var(--lyrics-active-opacity);
|
||||
animation: var(--lyrics-animations);
|
||||
}
|
||||
|
||||
.upcoming > .text-lyrics {
|
||||
}
|
||||
|
||||
.lyrics-renderer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { SongInfo } from '@/providers/song-info';
|
||||
import type { SongInfo } from '@/providers/song-info';
|
||||
|
||||
export type SyncedLyricsPluginConfig = {
|
||||
enabled: boolean;
|
||||
@ -7,6 +7,7 @@ export type SyncedLyricsPluginConfig = {
|
||||
defaultTextString: string;
|
||||
showLyricsEvenIfInexact: boolean;
|
||||
lineEffect: LineEffect;
|
||||
romanization: boolean;
|
||||
};
|
||||
|
||||
export type LineLyricsStatus = 'previous' | 'current' | 'upcoming';
|
||||
|
||||
Reference in New Issue
Block a user