mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 10:31:47 +00:00
feat(synced-lyrics): multiple lyric sources (#2383)
Co-authored-by: JellyBrick <shlee1503@naver.com>
This commit is contained in:
@ -195,6 +195,7 @@
|
||||
"start": "electron-vite preview",
|
||||
"start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm start",
|
||||
"dev": "cross-env NODE_OPTIONS=--enable-source-maps electron-vite dev --watch",
|
||||
"dev:renderer": "cross-env NODE_OPTIONS=--enable-source-maps electron-vite dev",
|
||||
"dev:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm dev",
|
||||
"clean": "del-cli dist && del-cli pack && del-cli .vite-inspect",
|
||||
"dist": "pnpm clean && pnpm build && pnpm electron-builder --win --mac --linux -p never",
|
||||
|
||||
@ -714,8 +714,8 @@
|
||||
"synced-lyrics": {
|
||||
"description": "Provides synced lyrics to songs, using providers like LRClib.",
|
||||
"errors": {
|
||||
"fetch": "⚠️ - An error occurred while fetching the lyrics. Please try again later.",
|
||||
"not-found": "⚠️ - No lyrics found for this song."
|
||||
"fetch": "⚠️\tAn error occurred while fetching the lyrics.\n\tPlease try again later.",
|
||||
"not-found": "⚠️ No lyrics found for this song."
|
||||
},
|
||||
"menu": {
|
||||
"default-text-string": {
|
||||
|
||||
18
src/index.ts
18
src/index.ts
@ -132,7 +132,7 @@ if (config.get('options.disableHardwareAcceleration')) {
|
||||
|
||||
if (is.linux()) {
|
||||
// Overrides WM_CLASS for X11 to correspond to icon filename
|
||||
app.setName("com.github.th_ch.youtube_music");
|
||||
app.setName('com.github.th_ch.youtube_music');
|
||||
|
||||
// Workaround for issue #2248
|
||||
if (
|
||||
@ -904,9 +904,19 @@ function removeContentSecurityPolicy(
|
||||
betterSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
details.responseHeaders ??= {};
|
||||
|
||||
// Remove the content security policy
|
||||
delete details.responseHeaders['content-security-policy-report-only'];
|
||||
delete details.responseHeaders['content-security-policy'];
|
||||
// prettier-ignore
|
||||
if (new URL(details.url).protocol === 'https:') {
|
||||
// Remove the content security policy
|
||||
delete details.responseHeaders['content-security-policy-report-only'];
|
||||
delete details.responseHeaders['Content-Security-Policy-Report-Only'];
|
||||
delete details.responseHeaders['content-security-policy'];
|
||||
delete details.responseHeaders['Content-Security-Policy'];
|
||||
|
||||
// Only allow cross-origin requests from music.youtube.com
|
||||
delete details.responseHeaders['access-control-allow-origin'];
|
||||
delete details.responseHeaders['Access-Control-Allow-Origin'];
|
||||
details.responseHeaders['access-control-allow-origin'] = ['https://music.youtube.com'];
|
||||
}
|
||||
|
||||
callback({ cancel: false, responseHeaders: details.responseHeaders });
|
||||
});
|
||||
|
||||
@ -18,8 +18,8 @@ export interface Section {
|
||||
|
||||
export interface Hit {
|
||||
highlights: Highlight[];
|
||||
index: Index;
|
||||
type: Index;
|
||||
index: ResultType;
|
||||
type: ResultType;
|
||||
result: Result;
|
||||
}
|
||||
|
||||
@ -35,14 +35,10 @@ export interface Range {
|
||||
end: number;
|
||||
}
|
||||
|
||||
export enum Index {
|
||||
Album = 'album',
|
||||
Lyric = 'lyric',
|
||||
Song = 'song',
|
||||
}
|
||||
export type ResultType = 'song' | 'album' | 'lyric';
|
||||
|
||||
export interface Result {
|
||||
_type: Index;
|
||||
_type: ResultType;
|
||||
annotation_count?: number;
|
||||
api_path: string;
|
||||
artist_names?: string;
|
||||
|
||||
@ -20,7 +20,7 @@ export default createPlugin({
|
||||
showTimeCodes: false,
|
||||
defaultTextString: '♪',
|
||||
lineEffect: 'scale',
|
||||
} satisfies SyncedLyricsPluginConfig,
|
||||
} as SyncedLyricsPluginConfig,
|
||||
|
||||
menu,
|
||||
renderer,
|
||||
|
||||
@ -5,13 +5,10 @@ import { t } from '@/i18n';
|
||||
import type { MenuContext } from '@/types/contexts';
|
||||
import type { SyncedLyricsPluginConfig } from './types';
|
||||
|
||||
export const menu = async ({
|
||||
getConfig,
|
||||
setConfig,
|
||||
}: MenuContext<SyncedLyricsPluginConfig>): Promise<
|
||||
export const menu = async (ctx: MenuContext<SyncedLyricsPluginConfig>): Promise<
|
||||
MenuItemConstructorOptions[]
|
||||
> => {
|
||||
const config = await getConfig();
|
||||
const config = await ctx.getConfig();
|
||||
|
||||
return [
|
||||
{
|
||||
@ -20,7 +17,7 @@ export const menu = async ({
|
||||
type: 'checkbox',
|
||||
checked: config.preciseTiming,
|
||||
click(item) {
|
||||
setConfig({
|
||||
ctx.setConfig({
|
||||
preciseTiming: item.checked,
|
||||
});
|
||||
},
|
||||
@ -40,7 +37,7 @@ export const menu = async ({
|
||||
type: 'radio',
|
||||
checked: config.lineEffect === 'scale',
|
||||
click() {
|
||||
setConfig({
|
||||
ctx.setConfig({
|
||||
lineEffect: 'scale',
|
||||
});
|
||||
},
|
||||
@ -55,7 +52,7 @@ export const menu = async ({
|
||||
type: 'radio',
|
||||
checked: config.lineEffect === 'offset',
|
||||
click() {
|
||||
setConfig({
|
||||
ctx.setConfig({
|
||||
lineEffect: 'offset',
|
||||
});
|
||||
},
|
||||
@ -70,7 +67,7 @@ export const menu = async ({
|
||||
type: 'radio',
|
||||
checked: config.lineEffect === 'focus',
|
||||
click() {
|
||||
setConfig({
|
||||
ctx.setConfig({
|
||||
lineEffect: 'focus',
|
||||
});
|
||||
},
|
||||
@ -87,7 +84,7 @@ export const menu = async ({
|
||||
type: 'radio',
|
||||
checked: config.defaultTextString === '♪',
|
||||
click() {
|
||||
setConfig({
|
||||
ctx.setConfig({
|
||||
defaultTextString: '♪',
|
||||
});
|
||||
},
|
||||
@ -97,7 +94,7 @@ export const menu = async ({
|
||||
type: 'radio',
|
||||
checked: config.defaultTextString === ' ',
|
||||
click() {
|
||||
setConfig({
|
||||
ctx.setConfig({
|
||||
defaultTextString: ' ',
|
||||
});
|
||||
},
|
||||
@ -107,7 +104,7 @@ export const menu = async ({
|
||||
type: 'radio',
|
||||
checked: config.defaultTextString === '...',
|
||||
click() {
|
||||
setConfig({
|
||||
ctx.setConfig({
|
||||
defaultTextString: '...',
|
||||
});
|
||||
},
|
||||
@ -117,7 +114,7 @@ export const menu = async ({
|
||||
type: 'radio',
|
||||
checked: config.defaultTextString === '———',
|
||||
click() {
|
||||
setConfig({
|
||||
ctx.setConfig({
|
||||
defaultTextString: '———',
|
||||
});
|
||||
},
|
||||
@ -130,7 +127,7 @@ export const menu = async ({
|
||||
type: 'checkbox',
|
||||
checked: config.showTimeCodes,
|
||||
click(item) {
|
||||
setConfig({
|
||||
ctx.setConfig({
|
||||
showTimeCodes: item.checked,
|
||||
});
|
||||
},
|
||||
@ -143,7 +140,7 @@ export const menu = async ({
|
||||
type: 'checkbox',
|
||||
checked: config.showLyricsEvenIfInexact,
|
||||
click(item) {
|
||||
setConfig({
|
||||
ctx.setConfig({
|
||||
showLyricsEvenIfInexact: item.checked,
|
||||
});
|
||||
},
|
||||
|
||||
89
src/plugins/synced-lyrics/parsers/lrc.ts
Normal file
89
src/plugins/synced-lyrics/parsers/lrc.ts
Normal file
@ -0,0 +1,89 @@
|
||||
interface LRCTag {
|
||||
tag: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface LRCLine {
|
||||
time: string;
|
||||
timeInMs: number;
|
||||
duration: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface LRC {
|
||||
tags: LRCTag[];
|
||||
lines: LRCLine[];
|
||||
}
|
||||
|
||||
const tagRegex = /^\[(?<tag>\w+):\s*(?<value>.+?)\s*\]$/;
|
||||
// prettier-ignore
|
||||
const lyricRegex = /^\[(?<minutes>\d+):(?<seconds>\d+)\.(?<milliseconds>\d+)\](?<text>.+)$/;
|
||||
|
||||
export const LRC = {
|
||||
parse: (text: string): LRC => {
|
||||
const lrc: LRC = {
|
||||
tags: [],
|
||||
lines: [],
|
||||
};
|
||||
|
||||
let offset = 0;
|
||||
let previousLine: LRCLine | null = null;
|
||||
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line.trim().startsWith('[')) continue;
|
||||
|
||||
const lyric = line.match(lyricRegex)?.groups;
|
||||
if (!lyric) {
|
||||
const tag = line.match(tagRegex)?.groups;
|
||||
if (tag) {
|
||||
if (tag.tag === 'offset') {
|
||||
offset = parseInt(tag.value);
|
||||
continue;
|
||||
}
|
||||
|
||||
lrc.tags.push({
|
||||
tag: tag.tag,
|
||||
value: tag.value,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const { minutes, seconds, milliseconds, text } = lyric;
|
||||
const timeInMs =
|
||||
parseInt(minutes) * 60 * 1000 +
|
||||
parseInt(seconds) * 1000 +
|
||||
parseInt(milliseconds);
|
||||
|
||||
const currentLine: LRCLine = {
|
||||
time: `${minutes}:${seconds}:${milliseconds}`,
|
||||
timeInMs,
|
||||
text: text.trim(),
|
||||
duration: Infinity,
|
||||
};
|
||||
|
||||
if (previousLine) {
|
||||
previousLine.duration = timeInMs - previousLine.timeInMs;
|
||||
}
|
||||
|
||||
previousLine = currentLine;
|
||||
lrc.lines.push(currentLine);
|
||||
}
|
||||
|
||||
for (const line of lrc.lines) {
|
||||
line.timeInMs += offset;
|
||||
}
|
||||
|
||||
const first = lrc.lines.at(0);
|
||||
if (first && first.timeInMs > 300) {
|
||||
lrc.lines.unshift({
|
||||
time: '0:0:0',
|
||||
timeInMs: 0,
|
||||
duration: first.timeInMs,
|
||||
text: '',
|
||||
});
|
||||
}
|
||||
|
||||
return lrc;
|
||||
},
|
||||
};
|
||||
137
src/plugins/synced-lyrics/providers/LRCLib.ts
Normal file
137
src/plugins/synced-lyrics/providers/LRCLib.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { jaroWinkler } from '@skyra/jaro-winkler';
|
||||
|
||||
import { config } from '../renderer/renderer';
|
||||
import { LRC } from '../parsers/lrc';
|
||||
|
||||
import type { LyricProvider, LyricResult, SearchSongInfo } from '../types';
|
||||
|
||||
export class LRCLib implements LyricProvider {
|
||||
name = 'LRCLib';
|
||||
baseUrl = 'https://lrclib.net';
|
||||
|
||||
async search({
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
songDuration,
|
||||
}: SearchSongInfo): Promise<LyricResult | null> {
|
||||
let query = new URLSearchParams({
|
||||
artist_name: artist,
|
||||
track_name: title,
|
||||
});
|
||||
|
||||
query.set('album_name', album!);
|
||||
if (query.get('album_name') === 'undefined') {
|
||||
query.delete('album_name');
|
||||
}
|
||||
|
||||
let url = `${this.baseUrl}/api/search?${query.toString()}`;
|
||||
let response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`bad HTTPStatus(${response.statusText})`);
|
||||
}
|
||||
|
||||
let data = (await response.json()) as LRCLIBSearchResponse;
|
||||
if (!data || !Array.isArray(data)) {
|
||||
throw new Error(`Expected an array, instead got ${typeof data}`);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
if (!config()?.showLyricsEvenIfInexact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
query = new URLSearchParams({ q: title });
|
||||
url = `${this.baseUrl}/api/search?${query.toString()}`;
|
||||
|
||||
response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`bad HTTPStatus(${response.statusText})`);
|
||||
}
|
||||
|
||||
data = (await response.json()) as LRCLIBSearchResponse;
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`Expected an array, instead got ${typeof data}`);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredResults = [];
|
||||
for (const item of data) {
|
||||
const { artistName } = item;
|
||||
|
||||
const artists = artist.split(/[&,]/g).map((i) => i.trim());
|
||||
const itemArtists = artistName.split(/[&,]/g).map((i) => i.trim());
|
||||
|
||||
const permutations = [];
|
||||
for (const artistA of artists) {
|
||||
for (const artistB of itemArtists) {
|
||||
permutations.push([artistA.toLowerCase(), artistB.toLowerCase()]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const artistA of itemArtists) {
|
||||
for (const artistB of artists) {
|
||||
permutations.push([artistA.toLowerCase(), artistB.toLowerCase()]);
|
||||
}
|
||||
}
|
||||
|
||||
const ratio = Math.max(
|
||||
...permutations.map(([x, y]) => jaroWinkler(x, y)),
|
||||
);
|
||||
|
||||
if (ratio <= 0.9) continue;
|
||||
filteredResults.push(item);
|
||||
}
|
||||
|
||||
filteredResults.sort(({ duration: durationA }, { duration: durationB }) => {
|
||||
const left = Math.abs(durationA - songDuration);
|
||||
const right = Math.abs(durationB - songDuration);
|
||||
|
||||
return left - right;
|
||||
});
|
||||
|
||||
const closestResult = filteredResults[0];
|
||||
if (!closestResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Math.abs(closestResult.duration - songDuration) > 15) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (closestResult.instrumental) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = closestResult.syncedLyrics;
|
||||
const plain = closestResult.plainLyrics;
|
||||
if (!raw && !plain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: closestResult.trackName,
|
||||
artists: closestResult.artistName.split(/[&,]/g),
|
||||
lines: raw
|
||||
? LRC.parse(raw).lines.map((l) => ({
|
||||
...l,
|
||||
status: 'upcoming' as const,
|
||||
}))
|
||||
: undefined,
|
||||
lyrics: plain,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type LRCLIBSearchResponse = {
|
||||
id: number;
|
||||
name: string;
|
||||
trackName: string;
|
||||
artistName: string;
|
||||
albumName: string;
|
||||
duration: number;
|
||||
instrumental: boolean;
|
||||
plainLyrics: string;
|
||||
syncedLyrics: string;
|
||||
}[];
|
||||
132
src/plugins/synced-lyrics/providers/LyricsGenius.ts
Normal file
132
src/plugins/synced-lyrics/providers/LyricsGenius.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import type { LyricProvider, LyricResult, SearchSongInfo } from '../types';
|
||||
|
||||
const preloadedStateRegex = /__PRELOADED_STATE__ = JSON\.parse\('(.*?)'\);/;
|
||||
const preloadHtmlRegex = /body":{"html":"(.*?)","children"/;
|
||||
|
||||
export class LyricsGenius implements LyricProvider {
|
||||
public name = 'Genius';
|
||||
public baseUrl = 'https://genius.com';
|
||||
private domParser = new DOMParser();
|
||||
|
||||
// prettier-ignore
|
||||
async search({ title, artist }: SearchSongInfo): Promise<LyricResult | null> {
|
||||
const query = new URLSearchParams({
|
||||
q: `${artist} ${title}`,
|
||||
page: '1',
|
||||
per_page: '10',
|
||||
});
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/search/song?${query}`);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as LyricsGeniusSearch;
|
||||
const hits = data.response.sections[0].hits;
|
||||
|
||||
hits.sort(
|
||||
({
|
||||
result: {
|
||||
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);
|
||||
|
||||
return pointsB - pointsA;
|
||||
},
|
||||
);
|
||||
|
||||
const closestHit = hits.at(0);
|
||||
if (!closestHit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { result: { path } } = closestHit;
|
||||
|
||||
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 preloadedState = preloadedStateScript.textContent?.match(preloadedStateRegex)?.[1]?.replace(/\\"/g, '"');
|
||||
|
||||
const lyricsHtml = preloadedState?.match(preloadHtmlRegex)?.[1]
|
||||
?.replace(/\\\//g, '/')
|
||||
?.replace(/\\\\/g, '\\')
|
||||
?.replace(/\\n/g, '\n')
|
||||
?.replace(/\\'/g, "'")
|
||||
?.replace(/\\"/g, '"');
|
||||
|
||||
if (!lyricsHtml) 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;
|
||||
|
||||
return {
|
||||
title: closestHit.result.title,
|
||||
artists: closestHit.result.primary_artists.map(({ name }) => name),
|
||||
lyrics,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface LyricsGeniusSearch {
|
||||
response: Response;
|
||||
}
|
||||
|
||||
interface Response {
|
||||
sections: Section[];
|
||||
}
|
||||
|
||||
interface Section {
|
||||
hits: {
|
||||
highlights: unknown[];
|
||||
index: string;
|
||||
type: string;
|
||||
result: Result;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface Result {
|
||||
api_path: string;
|
||||
artist_names: string;
|
||||
full_title: string;
|
||||
id: number;
|
||||
instrumental: boolean;
|
||||
path: string;
|
||||
release_date_components: ReleaseDateComponents;
|
||||
title: string;
|
||||
title_with_featured: string;
|
||||
updated_by_human_at: number;
|
||||
url: string;
|
||||
featured_artists: Artist[];
|
||||
primary_artist: Artist;
|
||||
primary_artists: Artist[];
|
||||
}
|
||||
|
||||
interface Artist {
|
||||
api_path: string;
|
||||
id: number;
|
||||
image_url: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ReleaseDateComponents {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
}
|
||||
110
src/plugins/synced-lyrics/providers/Megalobiz.ts
Normal file
110
src/plugins/synced-lyrics/providers/Megalobiz.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { jaroWinkler } from '@skyra/jaro-winkler';
|
||||
|
||||
import { LRC } from '../parsers/lrc';
|
||||
|
||||
import type { LyricProvider, LyricResult, SearchSongInfo } from '../types';
|
||||
|
||||
const removeNoise = (text: string) => {
|
||||
return text
|
||||
.replace(/\[.*?\]/g, '')
|
||||
.replace(/\(.*?\)/g, '')
|
||||
.trim()
|
||||
.replace(/(^[-•])|([-•]$)/g, '')
|
||||
.trim()
|
||||
.replace(/\s+by$/, '');
|
||||
};
|
||||
|
||||
export class Megalobiz implements LyricProvider {
|
||||
public name = 'Megalobiz';
|
||||
public baseUrl = 'https://www.megalobiz.com';
|
||||
private domParser = new DOMParser();
|
||||
|
||||
// prettier-ignore
|
||||
async search({ title, artist, songDuration }: SearchSongInfo): Promise<LyricResult | null> {
|
||||
const query = new URLSearchParams({
|
||||
qry: `${artist} ${title}`,
|
||||
});
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/search/all?${query}`, {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`bad HTTPStatus(${response.statusText})`);
|
||||
}
|
||||
|
||||
const data = await response.text();
|
||||
const searchDoc = this.domParser.parseFromString(data, 'text/html');
|
||||
|
||||
// prettier-ignore
|
||||
const searchResults: MegalobizSearchResult[] = Array.prototype.map
|
||||
.call(searchDoc.querySelectorAll('a.entity_name[href^="/lrc/maker/"][name][title]'),
|
||||
(anchor: HTMLAnchorElement) => {
|
||||
const { minutes, seconds, millis } = anchor
|
||||
.getAttribute('title')!
|
||||
.match(/\[(?<minutes>\d+):(?<seconds>\d+)\.(?<millis>\d+)\]/)!
|
||||
.groups!;
|
||||
|
||||
let name = anchor.getAttribute('name')!;
|
||||
|
||||
const artists = [
|
||||
removeNoise(name.match(/\(?[Ff]eat\. (.+)\)?/)?.[1] ?? ''),
|
||||
...(removeNoise(name).match(/(?<artists>.*?) [-•] (?<title>.*)/)?.groups?.artists?.split(/[&,]/)?.map(removeNoise) ?? []),
|
||||
...(removeNoise(name).match(/(?<title>.*) by (?<artists>.*)/)?.groups?.artists?.split(/[&,]/)?.map(removeNoise) ?? []),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const artist of artists) {
|
||||
name = name.replace(artist, '');
|
||||
name = removeNoise(name);
|
||||
}
|
||||
|
||||
if (jaroWinkler(title, name) < 0.8) return null;
|
||||
|
||||
return {
|
||||
title: name,
|
||||
artists,
|
||||
href: anchor.getAttribute('href')!,
|
||||
duration:
|
||||
parseInt(minutes) * 60 +
|
||||
parseInt(seconds) +
|
||||
parseInt(millis) / 1000,
|
||||
};
|
||||
},
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
const sortedResults = searchResults.sort(
|
||||
({ duration: durationA }, { duration: durationB }) => {
|
||||
const left = Math.abs(durationA - songDuration);
|
||||
const right = Math.abs(durationB - songDuration);
|
||||
|
||||
return left - right;
|
||||
},
|
||||
);
|
||||
|
||||
const closestResult = sortedResults[0];
|
||||
if (!closestResult) return null;
|
||||
if (Math.abs(closestResult.duration - songDuration) > 15) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const html = await fetch(`${this.baseUrl}${closestResult.href}`).then((r) => r.text());
|
||||
const lyricsDoc = this.domParser.parseFromString(html, 'text/html');
|
||||
const raw = lyricsDoc.querySelector('span[id^="lrc_"][id$="_lyrics"]')?.textContent;
|
||||
if (!raw) throw new Error('Failed to extract lyrics from page.');
|
||||
|
||||
const lyrics = LRC.parse(raw);
|
||||
|
||||
return {
|
||||
title: closestResult.title,
|
||||
artists: closestResult.artists,
|
||||
lines: lyrics.lines.map((l) => ({ ...l, status: 'upcoming' })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface MegalobizSearchResult {
|
||||
title: string;
|
||||
artists: string[];
|
||||
href: string;
|
||||
duration: number;
|
||||
}
|
||||
10
src/plugins/synced-lyrics/providers/MusixMatch.ts
Normal file
10
src/plugins/synced-lyrics/providers/MusixMatch.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { LyricProvider, LyricResult, SearchSongInfo } from '../types';
|
||||
|
||||
export class MusixMatch implements LyricProvider {
|
||||
name = 'MusixMatch';
|
||||
baseUrl = 'https://www.musixmatch.com/';
|
||||
|
||||
search(_: SearchSongInfo): Promise<LyricResult | null> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
201
src/plugins/synced-lyrics/providers/YTMusic.ts
Normal file
201
src/plugins/synced-lyrics/providers/YTMusic.ts
Normal file
@ -0,0 +1,201 @@
|
||||
import type { LyricProvider, LyricResult, SearchSongInfo } from '../types';
|
||||
|
||||
const headers = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const client = {
|
||||
clientName: '26',
|
||||
clientVersion: '7.01.05',
|
||||
};
|
||||
|
||||
export class YTMusic implements LyricProvider {
|
||||
public name = 'YTMusic';
|
||||
public baseUrl = 'https://music.youtube.com/';
|
||||
|
||||
// prettier-ignore
|
||||
public async search(
|
||||
{ videoId, title, artist }: SearchSongInfo,
|
||||
): Promise<LyricResult | null> {
|
||||
const data = await this.fetchNext(videoId);
|
||||
|
||||
const { tabs } =
|
||||
data?.contents?.singleColumnMusicWatchNextResultsRenderer?.tabbedRenderer
|
||||
?.watchNextTabbedResultsRenderer ?? {};
|
||||
if (!Array.isArray(tabs)) return null;
|
||||
|
||||
const lyricsTab = tabs.find((it) => {
|
||||
const pageType = it?.tabRenderer?.endpoint?.browseEndpoint
|
||||
?.browseEndpointContextSupportedConfigs
|
||||
?.browseEndpointContextMusicConfig?.pageType;
|
||||
return pageType === 'MUSIC_PAGE_TYPE_TRACK_LYRICS';
|
||||
});
|
||||
|
||||
if (!lyricsTab) return null;
|
||||
|
||||
const { browseId } = lyricsTab?.tabRenderer?.endpoint?.browseEndpoint ?? {};
|
||||
if (!browseId) return null;
|
||||
|
||||
const { contents } = await this.fetchBrowse(browseId);
|
||||
if (!contents) return null;
|
||||
|
||||
/*
|
||||
NOTE: Due to the nature of Youtubei, the json responses are not consistent,
|
||||
this means we have to check for multiple possible paths to get the lyrics.
|
||||
*/
|
||||
|
||||
const syncedLines = contents?.elementRenderer?.newElement?.type
|
||||
?.componentType?.model?.timedLyricsModel?.lyricsData?.timedLyricsData;
|
||||
|
||||
const synced = syncedLines?.length && syncedLines[0]?.cueRange
|
||||
? syncedLines.map((it) => ({
|
||||
time: this.millisToTime(parseInt(it.cueRange.startTimeMilliseconds)),
|
||||
timeInMs: parseInt(it.cueRange.startTimeMilliseconds),
|
||||
duration: parseInt(it.cueRange.endTimeMilliseconds) -
|
||||
parseInt(it.cueRange.startTimeMilliseconds),
|
||||
text: it.lyricLine.trim() === '♪' ? '' : it.lyricLine.trim(),
|
||||
status: 'upcoming' as const,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const plain = !synced
|
||||
? syncedLines?.length
|
||||
? syncedLines.map((it) => it.lyricLine).join('\n')
|
||||
: contents?.messageRenderer
|
||||
? contents?.messageRenderer?.text?.runs?.map((it) => it.text).join('\n')
|
||||
: contents?.sectionListRenderer?.contents?.[0]
|
||||
?.musicDescriptionShelfRenderer?.description?.runs?.map((it) =>
|
||||
it.text
|
||||
)?.join('\n')
|
||||
: undefined;
|
||||
|
||||
if (typeof plain === 'string' && plain === 'Lyrics not available') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (synced?.length && synced[0].timeInMs > 300) {
|
||||
synced.unshift({
|
||||
duration: 0,
|
||||
text: '',
|
||||
time: '00:00.00',
|
||||
timeInMs: 0,
|
||||
status: 'upcoming' as const,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
artists: [artist],
|
||||
|
||||
lyrics: plain,
|
||||
lines: synced,
|
||||
};
|
||||
}
|
||||
|
||||
private millisToTime(millis: number) {
|
||||
const minutes = Math.floor(millis / 60000);
|
||||
const seconds = Math.floor((millis - minutes * 60 * 1000) / 1000);
|
||||
const remaining = (millis - minutes * 60 * 1000 - seconds * 1000) / 10;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds
|
||||
.toString()
|
||||
.padStart(2, '0')}.${remaining.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private ENDPOINT = 'https://youtubei.googleapis.com/youtubei/v1/';
|
||||
// RATE LIMITED (2 req per sec)
|
||||
private PROXIED_ENDPOINT = 'https://ytmbrowseproxy.zvz.be/';
|
||||
|
||||
private fetchNext(videoId: string) {
|
||||
return fetch(this.ENDPOINT + 'next?prettyPrint=false', {
|
||||
headers,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
videoId,
|
||||
context: { client },
|
||||
}),
|
||||
}).then((res) => res.json()) as Promise<NextData>;
|
||||
}
|
||||
|
||||
private fetchBrowse(browseId: string) {
|
||||
return fetch(this.PROXIED_ENDPOINT + 'browse?prettyPrint=false', {
|
||||
headers,
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
browseId,
|
||||
context: { client },
|
||||
}),
|
||||
}).then((res) => res.json()) as Promise<BrowseData>;
|
||||
}
|
||||
}
|
||||
|
||||
interface NextData {
|
||||
contents: {
|
||||
singleColumnMusicWatchNextResultsRenderer: {
|
||||
tabbedRenderer: {
|
||||
watchNextTabbedResultsRenderer: {
|
||||
tabs: {
|
||||
tabRenderer: {
|
||||
endpoint: {
|
||||
browseEndpoint: {
|
||||
browseId: string;
|
||||
browseEndpointContextSupportedConfigs: {
|
||||
browseEndpointContextMusicConfig: {
|
||||
pageType: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface BrowseData {
|
||||
contents: {
|
||||
elementRenderer: {
|
||||
newElement: {
|
||||
type: {
|
||||
componentType: {
|
||||
model: {
|
||||
timedLyricsModel: {
|
||||
lyricsData: {
|
||||
timedLyricsData: SyncedLyricLine[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
messageRenderer: {
|
||||
text: PlainLyricsTextRenderer;
|
||||
};
|
||||
sectionListRenderer: {
|
||||
contents: {
|
||||
musicDescriptionShelfRenderer: {
|
||||
description: PlainLyricsTextRenderer;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface SyncedLyricLine {
|
||||
lyricLine: string;
|
||||
cueRange: CueRange;
|
||||
}
|
||||
|
||||
interface CueRange {
|
||||
startTimeMilliseconds: string;
|
||||
endTimeMilliseconds: string;
|
||||
}
|
||||
|
||||
interface PlainLyricsTextRenderer {
|
||||
runs: {
|
||||
text: string;
|
||||
}[];
|
||||
}
|
||||
189
src/plugins/synced-lyrics/providers/index.ts
Normal file
189
src/plugins/synced-lyrics/providers/index.ts
Normal file
@ -0,0 +1,189 @@
|
||||
import { createStore } from 'solid-js/store';
|
||||
|
||||
import { createMemo } from 'solid-js';
|
||||
|
||||
import { SongInfo } from '@/providers/song-info';
|
||||
|
||||
import { LRCLib } from './LRCLib';
|
||||
import { LyricsGenius } from './LyricsGenius';
|
||||
import { YTMusic } from './YTMusic';
|
||||
|
||||
import { getSongInfo } from '@/providers/song-info-front';
|
||||
|
||||
import type { LyricProvider, LyricResult } from '../types';
|
||||
|
||||
export const providers = {
|
||||
YTMusic: new YTMusic(),
|
||||
LRCLib: new LRCLib(),
|
||||
LyricsGenius: new LyricsGenius(),
|
||||
// MusixMatch: new MusixMatch(),
|
||||
// Megalobiz: new Megalobiz(), // Disabled because it is too unstable and slow
|
||||
} as const;
|
||||
|
||||
export type ProviderName = keyof typeof providers;
|
||||
export const providerNames = Object.keys(providers) as ProviderName[];
|
||||
|
||||
export type ProviderState = {
|
||||
state: 'fetching' | 'done' | 'error';
|
||||
data: LyricResult | null;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
type LyricsStore = {
|
||||
provider: ProviderName;
|
||||
current: ProviderState;
|
||||
lyrics: Record<ProviderName, ProviderState>;
|
||||
};
|
||||
|
||||
const initialData = () =>
|
||||
providerNames.reduce(
|
||||
(acc, name) => {
|
||||
acc[name] = { state: 'fetching', data: null, error: null };
|
||||
return acc;
|
||||
},
|
||||
{} as LyricsStore['lyrics'],
|
||||
);
|
||||
|
||||
export const [lyricsStore, setLyricsStore] = createStore<LyricsStore>({
|
||||
provider: providerNames[0],
|
||||
lyrics: initialData(),
|
||||
get current(): ProviderState {
|
||||
return this.lyrics[this.provider];
|
||||
},
|
||||
});
|
||||
|
||||
export const currentLyrics = createMemo(() => {
|
||||
const provider = lyricsStore.provider;
|
||||
return lyricsStore.lyrics[provider];
|
||||
});
|
||||
|
||||
type VideoId = string;
|
||||
|
||||
type SearchCacheData = Record<ProviderName, ProviderState>;
|
||||
interface SearchCache {
|
||||
state: 'loading' | 'done';
|
||||
data: SearchCacheData;
|
||||
}
|
||||
|
||||
// TODO: Maybe use localStorage for the cache.
|
||||
const searchCache = new Map<VideoId, SearchCache>();
|
||||
export const fetchLyrics = (info: SongInfo) => {
|
||||
if (searchCache.has(info.videoId)) {
|
||||
const cache = searchCache.get(info.videoId)!;
|
||||
|
||||
if (cache.state === 'loading') {
|
||||
setTimeout(() => {
|
||||
fetchLyrics(info);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (getSongInfo().videoId === info.videoId) {
|
||||
setLyricsStore('lyrics', () => {
|
||||
// weird bug with solid-js
|
||||
return JSON.parse(JSON.stringify(cache.data)) as typeof cache.data;
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cache: SearchCache = {
|
||||
state: 'loading',
|
||||
data: initialData(),
|
||||
};
|
||||
|
||||
searchCache.set(info.videoId, cache);
|
||||
if (getSongInfo().videoId === info.videoId) {
|
||||
setLyricsStore('lyrics', () => {
|
||||
// weird bug with solid-js
|
||||
return JSON.parse(JSON.stringify(cache.data)) as typeof cache.data;
|
||||
});
|
||||
}
|
||||
|
||||
const tasks: Promise<void>[] = [];
|
||||
|
||||
// prettier-ignore
|
||||
for (
|
||||
const [providerName, provider] of Object.entries(providers) as [
|
||||
ProviderName,
|
||||
LyricProvider,
|
||||
][]
|
||||
) {
|
||||
const pCache = cache.data[providerName];
|
||||
|
||||
tasks.push(
|
||||
provider
|
||||
.search(info)
|
||||
.then((res) => {
|
||||
pCache.state = 'done';
|
||||
pCache.data = res;
|
||||
|
||||
if (getSongInfo().videoId === info.videoId) {
|
||||
setLyricsStore('lyrics', (old) => {
|
||||
return {
|
||||
...old,
|
||||
[providerName]: {
|
||||
state: 'done',
|
||||
data: res ? { ...res } : null,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
pCache.state = 'error';
|
||||
pCache.error = error;
|
||||
|
||||
if (getSongInfo().videoId === info.videoId) {
|
||||
setLyricsStore('lyrics', (old) => {
|
||||
return {
|
||||
...old,
|
||||
[providerName]: { state: 'error', error, data: null },
|
||||
};
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Promise.allSettled(tasks).then(() => {
|
||||
cache.state = 'done';
|
||||
searchCache.set(info.videoId, cache);
|
||||
});
|
||||
};
|
||||
|
||||
export const retrySearch = (provider: ProviderName, info: SongInfo) => {
|
||||
setLyricsStore('lyrics', (old) => {
|
||||
const pCache = {
|
||||
state: 'fetching',
|
||||
data: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
return {
|
||||
...old,
|
||||
[provider]: pCache,
|
||||
};
|
||||
});
|
||||
|
||||
providers[provider]
|
||||
.search(info)
|
||||
.then((res) => {
|
||||
setLyricsStore('lyrics', (old) => {
|
||||
return {
|
||||
...old,
|
||||
[provider]: { state: 'done', data: res, error: null },
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
setLyricsStore('lyrics', (old) => {
|
||||
return {
|
||||
...old,
|
||||
[provider]: { state: 'error', data: null, error },
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,64 @@
|
||||
import { t } from '@/i18n';
|
||||
|
||||
import { getSongInfo } from '@/providers/song-info-front';
|
||||
|
||||
import { lyricsStore, retrySearch } from '../../providers';
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
error: Error;
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
export const ErrorDisplay = (props: ErrorDisplayProps) => {
|
||||
return (
|
||||
<div style={{ 'margin-bottom': '5%' }}>
|
||||
<pre
|
||||
style={{
|
||||
'background-color': 'var(--ytmusic-color-black1)',
|
||||
'border-radius': '8px',
|
||||
'color': '#58f000',
|
||||
'max-width': '100%',
|
||||
'margin-top': '1em',
|
||||
'margin-bottom': '0',
|
||||
'padding': '0.5em',
|
||||
'font-family': 'serif',
|
||||
'font-size': 'large',
|
||||
}}
|
||||
>
|
||||
{t('plugins.synced-lyrics.errors.fetch')}
|
||||
</pre>
|
||||
<pre
|
||||
style={{
|
||||
'background-color': 'var(--ytmusic-color-black1)',
|
||||
'border-radius': '8px',
|
||||
'color': '#f0a500',
|
||||
'white-space': 'pre',
|
||||
'overflow-x': 'auto',
|
||||
'max-width': '100%',
|
||||
'margin-top': '0.5em',
|
||||
'padding': '0.5em',
|
||||
'font-family': 'monospace',
|
||||
'font-size': 'large',
|
||||
}}
|
||||
>
|
||||
{props.error.stack}
|
||||
</pre>
|
||||
|
||||
<yt-button-renderer
|
||||
onClick={() => retrySearch(lyricsStore.provider, getSongInfo())}
|
||||
data={{
|
||||
icon: { iconType: 'REFRESH' },
|
||||
isDisabled: false,
|
||||
style: 'STYLE_DEFAULT',
|
||||
text: {
|
||||
simpleText: t('plugins.synced-lyrics.refetch-btn.normal')
|
||||
},
|
||||
}}
|
||||
style={{
|
||||
'margin-top': '1em',
|
||||
'width': '100%'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
import { createSignal, onMount } from 'solid-js';
|
||||
|
||||
const states = [
|
||||
'(>_<)',
|
||||
'{ (>_<) }',
|
||||
'{{ (>_<) }}',
|
||||
'{{{ (>_<) }}}',
|
||||
'{{ (>_<) }}',
|
||||
'{ (>_<) }',
|
||||
];
|
||||
export const LoadingKaomoji = () => {
|
||||
const [counter, setCounter] = createSignal(0);
|
||||
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => setCounter((old) => old + 1), 500);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
return (
|
||||
<yt-formatted-string
|
||||
class="text-lyrics description ytmusic-description-shelf-renderer"
|
||||
style={{
|
||||
'display': 'inline-flex',
|
||||
'justify-content': 'center',
|
||||
'width': '100%',
|
||||
'user-select': 'none',
|
||||
}}
|
||||
text={{
|
||||
runs: [{ text: states[counter() % states.length] }],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -2,145 +2,54 @@ import { createSignal, For, Match, Show, Switch } from 'solid-js';
|
||||
|
||||
import { SyncedLine } from './SyncedLine';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
import { getSongInfo } from '@/providers/song-info-front';
|
||||
import { ErrorDisplay } from './ErrorDisplay';
|
||||
import { LoadingKaomoji } from './LoadingKaomoji';
|
||||
import { PlainLyrics } from './PlainLyrics';
|
||||
|
||||
import {
|
||||
differentDuration,
|
||||
hadSecondAttempt,
|
||||
isFetching,
|
||||
isInstrumental,
|
||||
makeLyricsRequest,
|
||||
} from '../lyrics/fetch';
|
||||
|
||||
import type { LineLyrics } from '../../types';
|
||||
import { currentLyrics, lyricsStore } from '../../providers';
|
||||
|
||||
export const [debugInfo, setDebugInfo] = createSignal<string>();
|
||||
export const [lineLyrics, setLineLyrics] = createSignal<LineLyrics[]>([]);
|
||||
export const [currentTime, setCurrentTime] = createSignal<number>(-1);
|
||||
|
||||
// prettier-ignore
|
||||
export const LyricsContainer = () => {
|
||||
const [error, setError] = createSignal('');
|
||||
|
||||
const onRefetch = async () => {
|
||||
if (isFetching()) return;
|
||||
setError('');
|
||||
|
||||
const info = getSongInfo();
|
||||
await makeLyricsRequest(info).catch((err) => {
|
||||
setError(String(err));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={'lyric-container'}>
|
||||
<div class="lyric-container">
|
||||
<Switch>
|
||||
<Match when={isFetching()}>
|
||||
<div style="margin-bottom: 8px;">
|
||||
<tp-yt-paper-spinner-lite
|
||||
active
|
||||
class="loading-indicator style-scope"
|
||||
/>
|
||||
</div>
|
||||
<Match when={currentLyrics()?.state === 'fetching'}>
|
||||
<LoadingKaomoji />
|
||||
</Match>
|
||||
<Match when={error()}>
|
||||
<Match when={!currentLyrics().data?.lines && !currentLyrics().data?.lyrics}>
|
||||
<yt-formatted-string
|
||||
class="warning-lyrics description ytmusic-description-shelf-renderer"
|
||||
class="text-lyrics description ytmusic-description-shelf-renderer"
|
||||
style={{
|
||||
'display': 'inline-flex',
|
||||
'justify-content': 'center',
|
||||
'width': '100%',
|
||||
'user-select': 'none',
|
||||
}}
|
||||
text={{
|
||||
runs: [
|
||||
{
|
||||
text: t('plugins.synced-lyrics.errors.fetch'),
|
||||
},
|
||||
],
|
||||
runs: [{ text: '\(〇_o)/' }],
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<Show when={lyricsStore.current.error}>
|
||||
<ErrorDisplay error={lyricsStore.current.error!} />
|
||||
</Show>
|
||||
|
||||
<Switch>
|
||||
<Match when={!lineLyrics().length}>
|
||||
<Show
|
||||
when={isInstrumental()}
|
||||
fallback={
|
||||
<>
|
||||
<yt-formatted-string
|
||||
class="warning-lyrics description ytmusic-description-shelf-renderer"
|
||||
text={{
|
||||
runs: [
|
||||
{
|
||||
text: t('plugins.synced-lyrics.errors.not-found'),
|
||||
},
|
||||
],
|
||||
}}
|
||||
style={'margin-bottom: 16px;'}
|
||||
/>
|
||||
<yt-button-renderer
|
||||
disabled={isFetching()}
|
||||
data={{
|
||||
icon: { iconType: 'REFRESH' },
|
||||
isDisabled: false,
|
||||
style: 'STYLE_DEFAULT',
|
||||
text: {
|
||||
simpleText: isFetching()
|
||||
? t('plugins.synced-lyrics.refetch-btn.fetching')
|
||||
: t('plugins.synced-lyrics.refetch-btn.normal'),
|
||||
},
|
||||
}}
|
||||
onClick={onRefetch}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<yt-formatted-string
|
||||
class="warning-lyrics description ytmusic-description-shelf-renderer"
|
||||
text={{
|
||||
runs: [
|
||||
{
|
||||
text: t('plugins.synced-lyrics.warnings.instrumental'),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Match when={currentLyrics().data?.lines}>
|
||||
<For each={currentLyrics().data?.lines}>
|
||||
{(item) => <SyncedLine line={item} />}
|
||||
</For>
|
||||
</Match>
|
||||
<Match when={lineLyrics().length && !hadSecondAttempt()}>
|
||||
<yt-formatted-string
|
||||
class="warning-lyrics description ytmusic-description-shelf-renderer"
|
||||
text={{
|
||||
runs: [
|
||||
{
|
||||
text: t('plugins.synced-lyrics.warnings.inexact'),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={lineLyrics().length && !differentDuration()}>
|
||||
<yt-formatted-string
|
||||
class="warning-lyrics description ytmusic-description-shelf-renderer"
|
||||
text={{
|
||||
runs: [
|
||||
{
|
||||
text: t('plugins.synced-lyrics.warnings.duration-mismatch'),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Match when={currentLyrics().data?.lyrics}>
|
||||
<PlainLyrics lyrics={currentLyrics().data?.lyrics!} />
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<For each={lineLyrics()}>{(item) => <SyncedLine line={item} />}</For>
|
||||
|
||||
<yt-formatted-string
|
||||
class="footer style-scope ytmusic-description-shelf-renderer"
|
||||
text={{
|
||||
runs: [
|
||||
{
|
||||
text: 'Source: LRCLIB',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
198
src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx
Normal file
198
src/plugins/synced-lyrics/renderer/components/LyricsPicker.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
Index,
|
||||
Match,
|
||||
onMount,
|
||||
Switch,
|
||||
} from 'solid-js';
|
||||
|
||||
import {
|
||||
currentLyrics,
|
||||
lyricsStore,
|
||||
ProviderName,
|
||||
providerNames,
|
||||
ProviderState,
|
||||
setLyricsStore,
|
||||
} from '../../providers';
|
||||
|
||||
import { _ytAPI } from '../index';
|
||||
|
||||
import type { YtIcons } from '@/types/icons';
|
||||
|
||||
export const providerIdx = createMemo(() =>
|
||||
providerNames.indexOf(lyricsStore.provider),
|
||||
);
|
||||
|
||||
const shouldSwitchProvider = (providerData: ProviderState) => {
|
||||
if (providerData.state === 'error') return true;
|
||||
if (providerData.state === 'fetching') return true;
|
||||
return (
|
||||
providerData.state === 'done' &&
|
||||
!providerData.data?.lines &&
|
||||
!providerData.data?.lyrics
|
||||
);
|
||||
};
|
||||
|
||||
const providerBias = (p: ProviderName) =>
|
||||
(lyricsStore.lyrics[p].state === 'done' ? 1 : -1) +
|
||||
(lyricsStore.lyrics[p].data?.lines?.length ? 2 : -1) +
|
||||
(lyricsStore.lyrics[p].data?.lines?.length && p === 'YTMusic' ? 1 : 0) +
|
||||
(lyricsStore.lyrics[p].data?.lyrics ? 1 : -1);
|
||||
|
||||
// prettier-ignore
|
||||
const pickBestProvider = () => {
|
||||
const providers = Array.from(providerNames);
|
||||
|
||||
providers.sort((a, b) => providerBias(b) - providerBias(a));
|
||||
|
||||
return providers[0];
|
||||
};
|
||||
|
||||
// prettier-ignore
|
||||
export const LyricsPicker = () => {
|
||||
const [hasManuallySwitchedProvider, setHasManuallySwitchedProvider] = createSignal(false);
|
||||
createEffect(() => {
|
||||
// fallback to the next source, if the current one has an error
|
||||
if (!hasManuallySwitchedProvider()
|
||||
) {
|
||||
const bestProvider = pickBestProvider();
|
||||
|
||||
const allProvidersFailed = providerNames.every((p) => shouldSwitchProvider(lyricsStore.lyrics[p]));
|
||||
if (allProvidersFailed) return;
|
||||
|
||||
if (providerBias(lyricsStore.provider) < providerBias(bestProvider)) {
|
||||
setLyricsStore('provider', bestProvider);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const listener = (name: string) => {
|
||||
if (name !== 'dataloaded') return;
|
||||
setHasManuallySwitchedProvider(false);
|
||||
};
|
||||
|
||||
_ytAPI?.addEventListener('videodatachange', listener);
|
||||
return () => _ytAPI?.removeEventListener('videodatachange', listener);
|
||||
});
|
||||
|
||||
const next = (automatic: boolean = false) => {
|
||||
if (!automatic) setHasManuallySwitchedProvider(true);
|
||||
setLyricsStore('provider', (prevProvider) => {
|
||||
const idx = providerNames.indexOf(prevProvider);
|
||||
return providerNames[(idx + 1) % providerNames.length];
|
||||
});
|
||||
};
|
||||
|
||||
const previous = (automatic: boolean = false) => {
|
||||
if (!automatic) setHasManuallySwitchedProvider(true);
|
||||
setLyricsStore('provider', (prevProvider) => {
|
||||
const idx = providerNames.indexOf(prevProvider);
|
||||
return providerNames[(idx + providerNames.length - 1) % providerNames.length];
|
||||
});
|
||||
};
|
||||
|
||||
const chevronLeft: YtIcons = 'yt-icons:chevron_left';
|
||||
const chevronRight: YtIcons = 'yt-icons:chevron_right';
|
||||
|
||||
const successIcon: YtIcons = 'yt-icons:check-circle';
|
||||
const errorIcon: YtIcons = 'yt-icons:error';
|
||||
const notFoundIcon: YtIcons = 'yt-icons:warning';
|
||||
|
||||
|
||||
return (
|
||||
<div class="lyrics-picker">
|
||||
<div class="lyrics-picker-left">
|
||||
<tp-yt-paper-icon-button icon={chevronLeft} onClick={() => previous()} />
|
||||
</div>
|
||||
|
||||
<div class="lyrics-picker-content">
|
||||
<div class="lyrics-picker-content-label">
|
||||
<Index each={providerNames}>
|
||||
{(provider) => (
|
||||
<div
|
||||
class="lyrics-picker-item"
|
||||
tabindex="-1"
|
||||
style={{
|
||||
transform: `translateX(${providerIdx() * -100 - 5}%)`,
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Match
|
||||
when={
|
||||
// prettier-ignore
|
||||
currentLyrics().state === 'fetching'
|
||||
}
|
||||
>
|
||||
<tp-yt-paper-spinner-lite
|
||||
active
|
||||
tabindex="-1"
|
||||
class="loading-indicator style-scope"
|
||||
style={{ padding: '5px', transform: 'scale(0.5)' }}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={currentLyrics().state === 'error'}>
|
||||
<tp-yt-paper-icon-button
|
||||
icon={errorIcon}
|
||||
tabindex="-1"
|
||||
style={{ padding: '5px', transform: 'scale(0.5)' }}
|
||||
/>
|
||||
</Match>
|
||||
<Match
|
||||
when={
|
||||
currentLyrics().state === 'done' &&
|
||||
(currentLyrics().data?.lines ||
|
||||
currentLyrics().data?.lyrics)
|
||||
}
|
||||
>
|
||||
<tp-yt-paper-icon-button
|
||||
icon={successIcon}
|
||||
tabindex="-1"
|
||||
style={{ padding: '5px', transform: 'scale(0.5)' }}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={
|
||||
currentLyrics().state === 'done'
|
||||
&& !currentLyrics().data?.lines
|
||||
&& !currentLyrics().data?.lyrics
|
||||
}>
|
||||
<tp-yt-paper-icon-button
|
||||
icon={notFoundIcon}
|
||||
tabindex="-1"
|
||||
style={{ padding: '5px', transform: 'scale(0.5)' }}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
<yt-formatted-string
|
||||
class="description ytmusic-description-shelf-renderer"
|
||||
text={{ runs: [{ text: provider() }] }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Index>
|
||||
</div>
|
||||
|
||||
<ul class="lyrics-picker-content-dots">
|
||||
<For each={providerNames}>
|
||||
{(_, idx) => (
|
||||
<li
|
||||
class="lyrics-picker-dot"
|
||||
onClick={() => setLyricsStore('provider', providerNames[idx()])}
|
||||
style={{
|
||||
background: idx() === providerIdx() ? 'white' : 'black',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="lyrics-picker-right">
|
||||
<tp-yt-paper-icon-button icon={chevronRight} onClick={() => next()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
import { createMemo, For } from 'solid-js';
|
||||
|
||||
interface PlainLyricsProps {
|
||||
lyrics: string;
|
||||
}
|
||||
|
||||
export const PlainLyrics = (props: PlainLyricsProps) => {
|
||||
const lines = createMemo(() => props.lyrics.split('\n'));
|
||||
|
||||
return (
|
||||
<div class="plain-lyrics">
|
||||
<For each={lines()}>
|
||||
{(line) => {
|
||||
if (line.trim() === '') {
|
||||
return <br />;
|
||||
} else {
|
||||
return (
|
||||
<yt-formatted-string
|
||||
class="text-lyrics description ytmusic-description-shelf-renderer"
|
||||
text={{
|
||||
runs: [{ text: line }],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -20,16 +20,22 @@ export const SyncedLine = ({ line }: SyncedLineProps) => {
|
||||
return 'current';
|
||||
});
|
||||
|
||||
let ref: HTMLDivElement;
|
||||
let ref: HTMLDivElement | undefined;
|
||||
createEffect(() => {
|
||||
if (status() === 'current') {
|
||||
ref.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
ref?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
});
|
||||
|
||||
const text = createMemo(() => {
|
||||
if (line.text.trim()) return line.text;
|
||||
return config()?.defaultTextString ?? '';
|
||||
});
|
||||
|
||||
// prettier-ignore
|
||||
return (
|
||||
<div
|
||||
ref={ref!}
|
||||
ref={ref}
|
||||
class={`synced-line ${status()}`}
|
||||
onClick={() => {
|
||||
_ytAPI?.seekTo(line.timeInMs / 1000);
|
||||
@ -39,13 +45,8 @@ export const SyncedLine = ({ line }: SyncedLineProps) => {
|
||||
class="text-lyrics description ytmusic-description-shelf-renderer"
|
||||
text={{
|
||||
runs: [
|
||||
{
|
||||
text: '',
|
||||
},
|
||||
{
|
||||
text: `${config()?.showTimeCodes ? `[${line.time}] ` : ''}${line.text}`,
|
||||
},
|
||||
],
|
||||
{ text: config()?.showTimeCodes ? `[${line.time}]` : '' },
|
||||
{ text: text() }],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import { createRenderer } from '@/utils';
|
||||
import { waitForElement } from '@/utils/wait-for-element';
|
||||
|
||||
import { makeLyricsRequest } from './lyrics';
|
||||
import { selectors, tabStates } from './utils';
|
||||
import { setConfig } from './renderer';
|
||||
import { setCurrentTime } from './components/LyricsContainer';
|
||||
|
||||
import { fetchLyrics } from '../providers';
|
||||
|
||||
import type { RendererContext } from '@/types/contexts';
|
||||
import type { YoutubePlayer } from '@/types/youtube-player';
|
||||
import type { SongInfo } from '@/providers/song-info';
|
||||
|
||||
import type { SyncedLyricsPluginConfig } from '../types';
|
||||
|
||||
export let _ytAPI: YoutubePlayer | null = null;
|
||||
@ -36,9 +36,7 @@ export const renderer = createRenderer<
|
||||
header.removeAttribute('disabled');
|
||||
break;
|
||||
case 'aria-selected':
|
||||
tabStates[header.ariaSelected as 'true' | 'false']?.(
|
||||
_ytAPI?.getVideoData(),
|
||||
);
|
||||
tabStates[header.ariaSelected ?? 'false']();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -51,7 +49,6 @@ export const renderer = createRenderer<
|
||||
|
||||
await this.videoDataChange();
|
||||
},
|
||||
|
||||
async videoDataChange() {
|
||||
if (!this.updateTimestampInterval) {
|
||||
this.updateTimestampInterval = setInterval(
|
||||
@ -60,12 +57,17 @@ export const renderer = createRenderer<
|
||||
);
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
this.observer ??= new MutationObserver(this.observerCallback);
|
||||
|
||||
// Force the lyrics tab to be enabled at all times.
|
||||
this.observer.disconnect();
|
||||
|
||||
// Force the lyrics tab to be enabled at all times.
|
||||
const header = await waitForElement<HTMLElement>(selectors.head);
|
||||
{
|
||||
header.removeAttribute('disabled');
|
||||
tabStates[header.ariaSelected ?? 'false']();
|
||||
}
|
||||
|
||||
this.observer.observe(header, { attributes: true });
|
||||
header.removeAttribute('disabled');
|
||||
},
|
||||
@ -73,8 +75,8 @@ export const renderer = createRenderer<
|
||||
async start(ctx: RendererContext<SyncedLyricsPluginConfig>) {
|
||||
setConfig(await ctx.getConfig());
|
||||
|
||||
ctx.ipc.on('ytmd:update-song-info', async (info: SongInfo) => {
|
||||
await makeLyricsRequest(info);
|
||||
ctx.ipc.on('ytmd:update-song-info', (info: SongInfo) => {
|
||||
fetchLyrics(info);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,198 +0,0 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { jaroWinkler } from '@skyra/jaro-winkler';
|
||||
|
||||
import { config } from '../renderer';
|
||||
|
||||
import { setDebugInfo, setLineLyrics } from '../components/LyricsContainer';
|
||||
|
||||
import type { SongInfo } from '@/providers/song-info';
|
||||
import type { LineLyrics, LRCLIBSearchResponse } from '../../types';
|
||||
|
||||
export const [isInstrumental, setIsInstrumental] = createSignal(false);
|
||||
export const [isFetching, setIsFetching] = createSignal(false);
|
||||
export const [hadSecondAttempt, setHadSecondAttempt] = createSignal(false);
|
||||
export const [differentDuration, setDifferentDuration] = createSignal(false);
|
||||
|
||||
export const extractTimeAndText = (
|
||||
line: string,
|
||||
index: number,
|
||||
): LineLyrics | null => {
|
||||
const groups = /\[(\d+):(\d+)\.(\d+)](.+)/.exec(line);
|
||||
if (!groups) return null;
|
||||
|
||||
const [, rMinutes, rSeconds, rMillis, text] = groups;
|
||||
const [minutes, seconds, millis] = [
|
||||
parseInt(rMinutes),
|
||||
parseInt(rSeconds),
|
||||
parseInt(rMillis),
|
||||
];
|
||||
|
||||
const timeInMs = minutes * 60 * 1000 + seconds * 1000 + millis;
|
||||
|
||||
return {
|
||||
index,
|
||||
timeInMs,
|
||||
time: `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${millis}`,
|
||||
text: text?.trim() || config()!.defaultTextString,
|
||||
status: 'upcoming',
|
||||
duration: 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const makeLyricsRequest = async (extractedSongInfo: SongInfo) => {
|
||||
setIsFetching(true);
|
||||
setLineLyrics([]);
|
||||
|
||||
const songData: Parameters<typeof getLyricsList>[0] = {
|
||||
title: `${extractedSongInfo.title}`,
|
||||
artist: `${extractedSongInfo.artist}`,
|
||||
songDuration: extractedSongInfo.songDuration,
|
||||
};
|
||||
|
||||
if (extractedSongInfo.album) {
|
||||
songData.album = extractedSongInfo.album;
|
||||
}
|
||||
|
||||
let lyrics;
|
||||
try {
|
||||
lyrics = await getLyricsList(songData);
|
||||
} catch {}
|
||||
|
||||
setLineLyrics(lyrics ?? []);
|
||||
setIsFetching(false);
|
||||
};
|
||||
|
||||
export const getLyricsList = async (
|
||||
songData: Pick<SongInfo, 'title' | 'artist' | 'album' | 'songDuration'>,
|
||||
): Promise<LineLyrics[] | null> => {
|
||||
setIsInstrumental(false);
|
||||
setHadSecondAttempt(false);
|
||||
setDifferentDuration(false);
|
||||
setDebugInfo('Searching for lyrics...');
|
||||
|
||||
let query = new URLSearchParams({
|
||||
artist_name: songData.artist,
|
||||
track_name: songData.title,
|
||||
});
|
||||
|
||||
if (songData.album) {
|
||||
query.set('album_name', songData.album);
|
||||
}
|
||||
|
||||
let url = `https://lrclib.net/api/search?${query.toString()}`;
|
||||
let response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
setDebugInfo('Got non-OK response from server.');
|
||||
return null;
|
||||
}
|
||||
|
||||
let data = (await response.json()) as LRCLIBSearchResponse;
|
||||
if (!data || !Array.isArray(data)) {
|
||||
setDebugInfo('Unexpected server response.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Note: If no lyrics are found, try again with a different search query
|
||||
if (data.length === 0) {
|
||||
if (!config()?.showLyricsEvenIfInexact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
query = new URLSearchParams({ q: songData.title });
|
||||
url = `https://lrclib.net/api/search?${query.toString()}`;
|
||||
|
||||
response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
setDebugInfo('Got non-OK response from server. (2)');
|
||||
return null;
|
||||
}
|
||||
|
||||
data = (await response.json()) as LRCLIBSearchResponse;
|
||||
if (!Array.isArray(data)) {
|
||||
setDebugInfo('Unexpected server response. (2)');
|
||||
return null;
|
||||
}
|
||||
|
||||
setHadSecondAttempt(true);
|
||||
}
|
||||
|
||||
const filteredResults: LRCLIBSearchResponse = [];
|
||||
for (const item of data) {
|
||||
const { artist } = songData;
|
||||
const { artistName } = item;
|
||||
|
||||
const artists = artist.split(/[&,]/g).map((i) => i.trim());
|
||||
const itemArtists = artistName.split(/[&,]/g).map((i) => i.trim());
|
||||
|
||||
const permutations = artists.flatMap((artistA) =>
|
||||
itemArtists.map((artistB) => [
|
||||
artistA.toLowerCase(),
|
||||
artistB.toLowerCase(),
|
||||
]),
|
||||
);
|
||||
|
||||
const ratio = Math.max(...permutations.map(([x, y]) => jaroWinkler(x, y)));
|
||||
if (ratio > 0.9) filteredResults.push(item);
|
||||
}
|
||||
|
||||
const duration = songData.songDuration;
|
||||
filteredResults.sort(({ duration: durationA }, { duration: durationB }) => {
|
||||
const left = Math.abs(durationA - duration);
|
||||
const right = Math.abs(durationB - duration);
|
||||
|
||||
return left - right;
|
||||
});
|
||||
|
||||
const closestResult = filteredResults[0];
|
||||
if (!closestResult) {
|
||||
setDebugInfo('No search result matched the criteria.');
|
||||
return null;
|
||||
}
|
||||
|
||||
setDebugInfo(JSON.stringify(closestResult, null, 4));
|
||||
|
||||
if (Math.abs(closestResult.duration - duration) > 15) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Math.abs(closestResult.duration - duration) > 5) {
|
||||
// show message that the timings may be wrong
|
||||
setDifferentDuration(true);
|
||||
}
|
||||
|
||||
setIsInstrumental(closestResult.instrumental);
|
||||
if (closestResult.instrumental) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Separate the lyrics into lines
|
||||
const raw = closestResult.syncedLyrics?.split('\n') ?? [];
|
||||
if (!raw.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add a blank line at the beginning
|
||||
raw.unshift('[0:0.0] ');
|
||||
|
||||
const syncedLyricList = raw.reduce<LineLyrics[]>((acc, line) => {
|
||||
const syncedLine = extractTimeAndText(line, acc.length);
|
||||
if (syncedLine) {
|
||||
acc.push(syncedLine);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
for (const line of syncedLyricList) {
|
||||
const next = syncedLyricList[line.index + 1];
|
||||
if (!next) {
|
||||
line.duration = Infinity;
|
||||
break;
|
||||
}
|
||||
|
||||
line.duration = next.timeInMs - line.timeInMs;
|
||||
}
|
||||
|
||||
return syncedLyricList;
|
||||
};
|
||||
@ -1,44 +0,0 @@
|
||||
import { createEffect } from 'solid-js';
|
||||
|
||||
import { config } from '../renderer';
|
||||
|
||||
export { makeLyricsRequest } from './fetch';
|
||||
|
||||
createEffect(() => {
|
||||
if (!config()?.enabled) return;
|
||||
const root = document.documentElement;
|
||||
|
||||
// Set the line effect
|
||||
switch (config()?.lineEffect) {
|
||||
case 'scale':
|
||||
root.style.setProperty(
|
||||
'--previous-lyrics',
|
||||
'var(--ytmusic-text-primary)',
|
||||
);
|
||||
root.style.setProperty('--current-lyrics', 'var(--ytmusic-text-primary)');
|
||||
root.style.setProperty('--size-lyrics', '1.2');
|
||||
root.style.setProperty('--offset-lyrics', '0');
|
||||
root.style.setProperty('--lyric-width', '83%');
|
||||
break;
|
||||
case 'offset':
|
||||
root.style.setProperty(
|
||||
'--previous-lyrics',
|
||||
'var(--ytmusic-text-primary)',
|
||||
);
|
||||
root.style.setProperty('--current-lyrics', 'var(--ytmusic-text-primary)');
|
||||
root.style.setProperty('--size-lyrics', '1');
|
||||
root.style.setProperty('--offset-lyrics', '5%');
|
||||
root.style.setProperty('--lyric-width', '100%');
|
||||
break;
|
||||
case 'focus':
|
||||
root.style.setProperty(
|
||||
'--previous-lyrics',
|
||||
'var(--ytmusic-text-secondary)',
|
||||
);
|
||||
root.style.setProperty('--current-lyrics', 'var(--ytmusic-text-primary)');
|
||||
root.style.setProperty('--size-lyrics', '1');
|
||||
root.style.setProperty('--offset-lyrics', '0');
|
||||
root.style.setProperty('--lyric-width', '100%');
|
||||
break;
|
||||
}
|
||||
});
|
||||
@ -1,22 +1,93 @@
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
import { createEffect, createSignal, onMount, Show } from 'solid-js';
|
||||
|
||||
import { LyricsContainer } from './components/LyricsContainer';
|
||||
import { LyricsPicker } from './components/LyricsPicker';
|
||||
|
||||
import { selectors } from './utils';
|
||||
|
||||
import type { VideoDetails } from '@/types/video-details';
|
||||
import type { SyncedLyricsPluginConfig } from '../types';
|
||||
|
||||
export const [isVisible, setIsVisible] = createSignal<boolean>(false);
|
||||
|
||||
export const [config, setConfig] =
|
||||
createSignal<SyncedLyricsPluginConfig | null>(null);
|
||||
export const [playerState, setPlayerState] = createSignal<VideoDetails | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
if (!config()?.enabled) return;
|
||||
const root = document.documentElement;
|
||||
|
||||
// Set the line effect
|
||||
switch (config()?.lineEffect) {
|
||||
case 'scale':
|
||||
root.style.setProperty(
|
||||
'--previous-lyrics',
|
||||
'var(--ytmusic-text-primary)',
|
||||
);
|
||||
root.style.setProperty('--current-lyrics', 'var(--ytmusic-text-primary)');
|
||||
root.style.setProperty('--size-lyrics', '1.2');
|
||||
root.style.setProperty('--offset-lyrics', '0');
|
||||
root.style.setProperty('--lyric-width', '83%');
|
||||
break;
|
||||
case 'offset':
|
||||
root.style.setProperty(
|
||||
'--previous-lyrics',
|
||||
'var(--ytmusic-text-primary)',
|
||||
);
|
||||
root.style.setProperty('--current-lyrics', 'var(--ytmusic-text-primary)');
|
||||
root.style.setProperty('--size-lyrics', '1');
|
||||
root.style.setProperty('--offset-lyrics', '5%');
|
||||
root.style.setProperty('--lyric-width', '100%');
|
||||
break;
|
||||
case 'focus':
|
||||
root.style.setProperty(
|
||||
'--previous-lyrics',
|
||||
'var(--ytmusic-text-secondary)',
|
||||
);
|
||||
root.style.setProperty('--current-lyrics', 'var(--ytmusic-text-primary)');
|
||||
root.style.setProperty('--size-lyrics', '1');
|
||||
root.style.setProperty('--offset-lyrics', '0');
|
||||
root.style.setProperty('--lyric-width', '100%');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
export const LyricsRenderer = () => {
|
||||
const [stickyRef, setStickRef] = createSignal<HTMLElement | null>(null);
|
||||
|
||||
// prettier-ignore
|
||||
onMount(() => {
|
||||
const tab = document.querySelector<HTMLElement>(selectors.body.tabRenderer)!;
|
||||
|
||||
const mousemoveListener = (e: MouseEvent) => {
|
||||
const { top } = tab.getBoundingClientRect();
|
||||
const { clientHeight: height } = stickyRef()!;
|
||||
|
||||
const showPicker = (e.clientY - top - 5) <= height;
|
||||
if (showPicker) {
|
||||
// picker visible
|
||||
stickyRef()!.style.setProperty('--top', '0');
|
||||
} else {
|
||||
// picker hidden
|
||||
stickyRef()!.style.setProperty('--top', '-50%');
|
||||
}
|
||||
};
|
||||
|
||||
tab.addEventListener('mousemove', mousemoveListener);
|
||||
return () => tab.removeEventListener('mousemove', mousemoveListener);
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={isVisible()}>
|
||||
<LyricsContainer />
|
||||
<div class="lyrics-renderer">
|
||||
<div class="lyrics-renderer-sticky" ref={setStickRef}>
|
||||
<LyricsPicker />
|
||||
<div
|
||||
id="divider"
|
||||
class="style-scope ytmusic-guide-section-renderer"
|
||||
style={{ width: '100%', margin: '0' }}
|
||||
></div>
|
||||
</div>
|
||||
<LyricsContainer />
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
import { render } from 'solid-js/web';
|
||||
|
||||
import { waitForElement } from '@/utils/wait-for-element';
|
||||
|
||||
import { LyricsRenderer, setIsVisible, setPlayerState } from './renderer';
|
||||
|
||||
import type { VideoDetails } from '@/types/video-details';
|
||||
import { LyricsRenderer, setIsVisible } from './renderer';
|
||||
|
||||
export const selectors = {
|
||||
head: '#tabsContent > .tab-header:nth-of-type(2)',
|
||||
@ -14,10 +11,9 @@ export const selectors = {
|
||||
},
|
||||
};
|
||||
|
||||
export const tabStates = {
|
||||
true: async (data?: VideoDetails) => {
|
||||
export const tabStates: Record<string, () => void> = {
|
||||
true: async () => {
|
||||
setIsVisible(true);
|
||||
setPlayerState(data ?? null);
|
||||
|
||||
let container = document.querySelector('#synced-lyrics-container');
|
||||
if (container) return;
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#tab-renderer[page-type='MUSIC_PAGE_TYPE_TRACK_LYRICS'] > #synced-lyrics-container {
|
||||
#tab-renderer[page-type='MUSIC_PAGE_TYPE_TRACK_LYRICS']
|
||||
> #synced-lyrics-container {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
@ -32,10 +33,10 @@
|
||||
|
||||
.synced-line {
|
||||
width: var(--lyric-width, 100%);
|
||||
}
|
||||
|
||||
.synced-line > .text-lyrics {
|
||||
cursor: pointer;
|
||||
& > .text-lyrics {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.synced-lyrics {
|
||||
@ -56,10 +57,17 @@
|
||||
display: block;
|
||||
text-align: left;
|
||||
margin: var(--global-margin) 0;
|
||||
transition: scale 0.3s ease-in-out, translate 0.3s ease-in-out, color 0.1s ease-in-out;
|
||||
transition:
|
||||
scale 0.3s ease-in-out,
|
||||
translate 0.3s ease-in-out,
|
||||
color 0.1s ease-in-out;
|
||||
transform-origin: 0 50%;
|
||||
}
|
||||
|
||||
.text-lyrics > span {
|
||||
margin-inline: 0.1em;
|
||||
}
|
||||
|
||||
.previous > .text-lyrics {
|
||||
color: var(--previous-lyrics);
|
||||
font-weight: normal;
|
||||
@ -76,3 +84,81 @@
|
||||
color: var(--upcoming-lyrics);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.lyrics-renderer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lyrics-picker {
|
||||
height: 5em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding-block: 1em;
|
||||
}
|
||||
|
||||
.lyrics-picker-content {
|
||||
display: flex;
|
||||
width: 50%;
|
||||
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lyrics-picker-content-label {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
/* padding-block: 5%; */
|
||||
}
|
||||
|
||||
.lyrics-picker-content-dots {
|
||||
display: block;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.lyrics-picker-item {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-width: 100%;
|
||||
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
transition: transform 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
.lyrics-picker-dot {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
margin: 0 4px 0;
|
||||
border-radius: 200px;
|
||||
border: 1px solid #6e7c7c7f;
|
||||
}
|
||||
|
||||
.lyrics-picker-left,
|
||||
.lyrics-picker-right {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: background-color 0.3s ease;
|
||||
border-radius: 25%;
|
||||
|
||||
&:hover {
|
||||
background-color: hsla(0, 0%, 100%, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.lyrics-renderer-sticky {
|
||||
position: sticky;
|
||||
top: var(--top, 0);
|
||||
z-index: 100;
|
||||
background-color: var(--ytmusic-background);
|
||||
|
||||
transition: top 325ms ease-in-out;
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { SongInfo } from '@/providers/song-info';
|
||||
|
||||
export type SyncedLyricsPluginConfig = {
|
||||
enabled: boolean;
|
||||
preciseTiming: boolean;
|
||||
@ -10,29 +12,30 @@ export type SyncedLyricsPluginConfig = {
|
||||
export type LineLyricsStatus = 'previous' | 'current' | 'upcoming';
|
||||
|
||||
export type LineLyrics = {
|
||||
index: number;
|
||||
time: string;
|
||||
timeInMs: number;
|
||||
text: string;
|
||||
duration: number;
|
||||
status: LineLyricsStatus;
|
||||
};
|
||||
|
||||
export type PlayPauseEvent = {
|
||||
isPaused: boolean;
|
||||
elapsedSeconds: number;
|
||||
text: string;
|
||||
status: LineLyricsStatus;
|
||||
};
|
||||
|
||||
export type LineEffect = 'scale' | 'offset' | 'focus';
|
||||
|
||||
export type LRCLIBSearchResponse = {
|
||||
id: number;
|
||||
export interface LyricResult {
|
||||
title: string;
|
||||
artists: string[];
|
||||
|
||||
lyrics?: string;
|
||||
lines?: LineLyrics[];
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
export type SearchSongInfo = Pick<SongInfo, 'title' | 'artist' | 'album' | 'songDuration' | 'videoId'>;
|
||||
|
||||
export interface LyricProvider {
|
||||
name: string;
|
||||
trackName: string;
|
||||
artistName: string;
|
||||
albumName: string;
|
||||
duration: number;
|
||||
instrumental: boolean;
|
||||
plainLyrics: string;
|
||||
syncedLyrics: string;
|
||||
}[];
|
||||
baseUrl: string;
|
||||
|
||||
search(songInfo: SearchSongInfo): Promise<LyricResult | null>;
|
||||
}
|
||||
|
||||
48
src/types/icons.ts
Normal file
48
src/types/icons.ts
Normal file
File diff suppressed because one or more lines are too long
9
src/yt-web-components.d.ts
vendored
9
src/yt-web-components.d.ts
vendored
@ -1,3 +1,5 @@
|
||||
import { Icons } from '@/types/icons';
|
||||
|
||||
import type { ComponentProps } from 'solid-js';
|
||||
|
||||
declare module 'solid-js' {
|
||||
@ -28,11 +30,18 @@ declare module 'solid-js' {
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
interface TpYtPaperIconButtonProps {
|
||||
icon: Icons;
|
||||
}
|
||||
|
||||
interface IntrinsicElements {
|
||||
center: ComponentProps<'div'>;
|
||||
'yt-formatted-string': ComponentProps<'span'> & YtFormattedStringProps;
|
||||
'yt-button-renderer': ComponentProps<'button'> & YtButtonRendererProps;
|
||||
'tp-yt-paper-spinner-lite': ComponentProps<'div'> &
|
||||
YpYtPaperSpinnerLiteProps;
|
||||
'tp-yt-paper-icon-button': ComponentProps<'div'> &
|
||||
TpYtPaperIconButtonProps;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user