mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-10 18:21:47 +00:00
plugin: Synced Lyrics (#2207)
* Added Plugin File * Added Logic * Known issue * Finished Backend part * Before cleanup * Added Style Removed log * Fixed time and visibility issues * Changed lyrics style * Changed way lyrics are selected * Fix * Added style lyrics options * Cleanup * Fix lyrics styling Changed how lyrics status are changed * Moved code to make file more readable * Change Tab Size * Fixed issue with overlapping lyrics * Removed debug console.log * Added style adaptation for music videos * Changed file indent * Revered back to original pnpm file * Removed unnecessary option * Fix lyrics status bug Removed leftover logs * Started to implement fetching for genius lyrics * feat(synced-lyrics): add `addedVersion` field * Made changes according to feedbacks * fix: add a delay of 300ms to the current time - Since the transition takes 300ms, we need to add a delay of 300ms to the current time * Removed test about genius.com scraping * Removed 300ms delay * chore: cleaned up the code * Specified path and variable * chore: always enable lyrics tab * chore: use SolidJS to render the lyrics * chore: remove useless signal * chore: feature-parity with original PR (+some nice stuff) * recreate lock file * show json decode error * feat(synced-lyrics): improve ui - Change type assertion code - Replace span to `yt-formatted-string` - Add refetch button * chore: make the lyric styling a solidjs effect * feat: i18n * chore: apply suggestion --------- Co-authored-by: Su-Yong <simssy2205@gmail.com> Co-authored-by: JellyBrick <shlee1503@naver.com> Co-authored-by: Angelos Bouklis <53124886+ArjixWasTaken@users.noreply.github.com>
This commit is contained in:
@ -1,17 +1,17 @@
|
|||||||
import { resolve, dirname, join } from 'node:path';
|
import { resolve, dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { UserConfig } from 'vite';
|
||||||
import { defineConfig, defineViteConfig } from 'electron-vite';
|
import { defineConfig, defineViteConfig } from 'electron-vite';
|
||||||
import builtinModules from 'builtin-modules';
|
import builtinModules from 'builtin-modules';
|
||||||
import viteResolve from 'vite-plugin-resolve';
|
import viteResolve from 'vite-plugin-resolve';
|
||||||
import Inspect from 'vite-plugin-inspect';
|
import Inspect from 'vite-plugin-inspect';
|
||||||
|
import solidPlugin from 'vite-plugin-solid';
|
||||||
|
|
||||||
import { pluginVirtualModuleGenerator } from './vite-plugins/plugin-importer.mjs';
|
import { pluginVirtualModuleGenerator } from './vite-plugins/plugin-importer.mjs';
|
||||||
import pluginLoader from './vite-plugins/plugin-loader.mjs';
|
import pluginLoader from './vite-plugins/plugin-loader.mjs';
|
||||||
|
|
||||||
import type { UserConfig } from 'vite';
|
|
||||||
import { i18nImporter } from './vite-plugins/i18n-importer.mjs';
|
import { i18nImporter } from './vite-plugins/i18n-importer.mjs';
|
||||||
import solidPlugin from 'vite-plugin-solid';
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@ -52,7 +52,10 @@ export default defineConfig({
|
|||||||
|
|
||||||
if (mode === 'development') {
|
if (mode === 'development') {
|
||||||
commonConfig.plugins?.push(
|
commonConfig.plugins?.push(
|
||||||
Inspect({ build: true, outputDir: join(__dirname, '.vite-inspect/backend') }),
|
Inspect({
|
||||||
|
build: true,
|
||||||
|
outputDir: join(__dirname, '.vite-inspect/backend'),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
return commonConfig;
|
return commonConfig;
|
||||||
}
|
}
|
||||||
@ -96,7 +99,10 @@ export default defineConfig({
|
|||||||
|
|
||||||
if (mode === 'development') {
|
if (mode === 'development') {
|
||||||
commonConfig.plugins?.push(
|
commonConfig.plugins?.push(
|
||||||
Inspect({ build: true, outputDir: join(__dirname, '.vite-inspect/preload') }),
|
Inspect({
|
||||||
|
build: true,
|
||||||
|
outputDir: join(__dirname, '.vite-inspect/preload'),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
return commonConfig;
|
return commonConfig;
|
||||||
}
|
}
|
||||||
@ -143,7 +149,10 @@ export default defineConfig({
|
|||||||
|
|
||||||
if (mode === 'development') {
|
if (mode === 'development') {
|
||||||
commonConfig.plugins?.push(
|
commonConfig.plugins?.push(
|
||||||
Inspect({ build: true, outputDir: join(__dirname, '.vite-inspect/renderer') }),
|
Inspect({
|
||||||
|
build: true,
|
||||||
|
outputDir: join(__dirname, '.vite-inspect/renderer'),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
return commonConfig;
|
return commonConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -164,6 +164,7 @@
|
|||||||
"@foobar404/wave": "2.0.5",
|
"@foobar404/wave": "2.0.5",
|
||||||
"@jellybrick/electron-better-web-request": "1.0.4",
|
"@jellybrick/electron-better-web-request": "1.0.4",
|
||||||
"@jellybrick/mpris-service": "2.1.4",
|
"@jellybrick/mpris-service": "2.1.4",
|
||||||
|
"@skyra/jaro-winkler": "^1.1.1",
|
||||||
"@xhayper/discord-rpc": "1.1.4",
|
"@xhayper/discord-rpc": "1.1.4",
|
||||||
"async-mutex": "0.5.0",
|
"async-mutex": "0.5.0",
|
||||||
"butterchurn": "3.0.0-beta.4",
|
"butterchurn": "3.0.0-beta.4",
|
||||||
|
|||||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@ -57,6 +57,9 @@ importers:
|
|||||||
'@jellybrick/mpris-service':
|
'@jellybrick/mpris-service':
|
||||||
specifier: 2.1.4
|
specifier: 2.1.4
|
||||||
version: 2.1.4
|
version: 2.1.4
|
||||||
|
'@skyra/jaro-winkler':
|
||||||
|
specifier: ^1.1.1
|
||||||
|
version: 1.1.1
|
||||||
'@xhayper/discord-rpc':
|
'@xhayper/discord-rpc':
|
||||||
specifier: 1.1.4
|
specifier: 1.1.4
|
||||||
version: 1.1.4(patch_hash=n7icacbfxuqlodunyqwwt5lccm)
|
version: 1.1.4(patch_hash=n7icacbfxuqlodunyqwwt5lccm)
|
||||||
@ -990,6 +993,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
'@skyra/jaro-winkler@1.1.1':
|
||||||
|
resolution: {integrity: sha512-jT2OWwpajtXTb6opnaIwmBTMpQtKUwl2Ro1zApxIIrpZJon71kZIv6GZSc08LzKO2lpTqUjvD+i7Z2hGuG42KQ==}
|
||||||
|
engines: {node: '>=v18'}
|
||||||
|
|
||||||
'@solid-primitives/refs@1.0.6':
|
'@solid-primitives/refs@1.0.6':
|
||||||
resolution: {integrity: sha512-ruh4YdVMxThEVnvqbpeLXKojW442vpFU8q7dSKtElGOTa31aKOAkRb9BTbdaTwVjN4BEq79fiiYIXozJNl4dSw==}
|
resolution: {integrity: sha512-ruh4YdVMxThEVnvqbpeLXKojW442vpFU8q7dSKtElGOTa31aKOAkRb9BTbdaTwVjN4BEq79fiiYIXozJNl4dSw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -4664,6 +4671,8 @@ snapshots:
|
|||||||
|
|
||||||
'@sindresorhus/is@4.6.0': {}
|
'@sindresorhus/is@4.6.0': {}
|
||||||
|
|
||||||
|
'@skyra/jaro-winkler@1.1.1': {}
|
||||||
|
|
||||||
'@solid-primitives/refs@1.0.6(solid-js@1.8.19)':
|
'@solid-primitives/refs@1.0.6(solid-js@1.8.19)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@solid-primitives/utils': 6.2.2(solid-js@1.8.19)
|
'@solid-primitives/utils': 6.2.2(solid-js@1.8.19)
|
||||||
@ -4984,7 +4993,7 @@ snapshots:
|
|||||||
|
|
||||||
app-builder-bin@4.0.0: {}
|
app-builder-bin@4.0.0: {}
|
||||||
|
|
||||||
app-builder-lib@24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)):
|
app-builder-lib@24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@develar/schema-utils': 2.6.5
|
'@develar/schema-utils': 2.6.5
|
||||||
'@electron/notarize': 2.2.1
|
'@electron/notarize': 2.2.1
|
||||||
@ -4998,7 +5007,7 @@ snapshots:
|
|||||||
builder-util-runtime: 9.2.4
|
builder-util-runtime: 9.2.4
|
||||||
chromium-pickle-js: 0.2.0
|
chromium-pickle-js: 0.2.0
|
||||||
debug: 4.3.5
|
debug: 4.3.5
|
||||||
dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3)
|
dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
|
||||||
ejs: 3.1.9
|
ejs: 3.1.9
|
||||||
electron-builder-squirrel-windows: 24.13.3(dmg-builder@24.13.3)
|
electron-builder-squirrel-windows: 24.13.3(dmg-builder@24.13.3)
|
||||||
electron-publish: 24.13.1
|
electron-publish: 24.13.1
|
||||||
@ -5630,9 +5639,9 @@ snapshots:
|
|||||||
|
|
||||||
discord-api-types@0.37.93: {}
|
discord-api-types@0.37.93: {}
|
||||||
|
|
||||||
dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3):
|
dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
app-builder-lib: 24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
|
app-builder-lib: 24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
|
||||||
builder-util: 24.13.1
|
builder-util: 24.13.1
|
||||||
builder-util-runtime: 9.2.4
|
builder-util-runtime: 9.2.4
|
||||||
fs-extra: 10.1.0
|
fs-extra: 10.1.0
|
||||||
@ -5704,7 +5713,7 @@ snapshots:
|
|||||||
|
|
||||||
electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3):
|
electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
app-builder-lib: 24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
|
app-builder-lib: 24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
|
||||||
archiver: 5.3.2
|
archiver: 5.3.2
|
||||||
builder-util: 24.13.1
|
builder-util: 24.13.1
|
||||||
fs-extra: 10.1.0
|
fs-extra: 10.1.0
|
||||||
@ -5714,11 +5723,11 @@ snapshots:
|
|||||||
|
|
||||||
electron-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)):
|
electron-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
app-builder-lib: 24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
|
app-builder-lib: 24.13.3(patch_hash=zcnm2qnjaggm2keyecnhiglkke)(dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)))(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
|
||||||
builder-util: 24.13.1
|
builder-util: 24.13.1
|
||||||
builder-util-runtime: 9.2.4
|
builder-util-runtime: 9.2.4
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3)
|
dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
|
||||||
fs-extra: 10.1.0
|
fs-extra: 10.1.0
|
||||||
is-ci: 3.0.1
|
is-ci: 3.0.1
|
||||||
lazy-val: 1.0.5
|
lazy-val: 1.0.5
|
||||||
|
|||||||
@ -418,19 +418,19 @@
|
|||||||
"presets": "Presets",
|
"presets": "Presets",
|
||||||
"skip-existing": "Skip existing files",
|
"skip-existing": "Skip existing files",
|
||||||
"download-finish-settings": {
|
"download-finish-settings": {
|
||||||
"label": "Download on finish",
|
"label": "Download on finish",
|
||||||
"submenu": {
|
"submenu": {
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"mode": "Time mode",
|
"mode": "Time mode",
|
||||||
"seconds": "Seconds",
|
"seconds": "Seconds",
|
||||||
"percent": "Percent",
|
"percent": "Percent",
|
||||||
"advanced": "Advanced"
|
"advanced": "Advanced"
|
||||||
},
|
},
|
||||||
"prompt": {
|
"prompt": {
|
||||||
"title": "Configure when to download",
|
"title": "Configure when to download",
|
||||||
"last-seconds": "Last x seconds",
|
"last-seconds": "Last x seconds",
|
||||||
"last-percent": "After x percent"
|
"last-percent": "After x percent"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"name": "Downloader",
|
"name": "Downloader",
|
||||||
@ -668,6 +668,23 @@
|
|||||||
"description": "Automatically Skips non-music parts like intro/outro or parts of music videos where the song isn't playing",
|
"description": "Automatically Skips non-music parts like intro/outro or parts of music videos where the song isn't playing",
|
||||||
"name": "SponsorBlock"
|
"name": "SponsorBlock"
|
||||||
},
|
},
|
||||||
|
"synced-lyrics": {
|
||||||
|
"description": "Provides synced lyrics to songs, using providers like LRClib.",
|
||||||
|
"name": "Synced Lyrics",
|
||||||
|
"errors": {
|
||||||
|
"fetch": "⚠️ - An error occurred while fetching the lyrics. Please try again later.",
|
||||||
|
"not-found": "⚠️ - No lyrics found for this song."
|
||||||
|
},
|
||||||
|
"warnings": {
|
||||||
|
"instrumental": "⚠️ - This is an instrumental song",
|
||||||
|
"inexact": "⚠️ - The lyrics for this song may not be exact",
|
||||||
|
"duration-mismatch": "⚠️ - The lyrics may be out of sync due to a duration mismatch."
|
||||||
|
},
|
||||||
|
"refetch-btn": {
|
||||||
|
"normal": "Refetch lyrics",
|
||||||
|
"fetching": "Fetching..."
|
||||||
|
}
|
||||||
|
},
|
||||||
"taskbar-mediacontrol": {
|
"taskbar-mediacontrol": {
|
||||||
"description": "Control playback from your Windows taskbar",
|
"description": "Control playback from your Windows taskbar",
|
||||||
"name": "Taskbar Media Control"
|
"name": "Taskbar Media Control"
|
||||||
|
|||||||
@ -77,6 +77,7 @@ export const onRendererLoad = ({
|
|||||||
applyLyricsTabState();
|
applyLyricsTabState();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyLyricsTabState = () => {
|
const applyLyricsTabState = () => {
|
||||||
if (lyrics) {
|
if (lyrics) {
|
||||||
tabs.lyrics.removeAttribute('disabled');
|
tabs.lyrics.removeAttribute('disabled');
|
||||||
@ -86,6 +87,7 @@ export const onRendererLoad = ({
|
|||||||
tabs.lyrics.setAttribute('aria-disabled', '');
|
tabs.lyrics.setAttribute('aria-disabled', '');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const lyricsTabHandler = () => {
|
const lyricsTabHandler = () => {
|
||||||
const tabContainer = document.querySelector('ytmusic-tab-renderer');
|
const tabContainer = document.querySelector('ytmusic-tab-renderer');
|
||||||
if (!tabContainer) return;
|
if (!tabContainer) return;
|
||||||
|
|||||||
28
src/plugins/synced-lyrics/index.ts
Normal file
28
src/plugins/synced-lyrics/index.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import style from './style.css?inline';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
import { SyncedLyricsPluginConfig } from './types';
|
||||||
|
|
||||||
|
import { menu } from './menu';
|
||||||
|
import { renderer } from './renderer';
|
||||||
|
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: () => t('plugins.synced-lyrics.name'),
|
||||||
|
description: () => t('plugins.synced-lyrics.description'),
|
||||||
|
authors: ['Non0reo', 'ArjixWasTaken'],
|
||||||
|
restartNeeded: true,
|
||||||
|
addedVersion: '3.4.X',
|
||||||
|
config: {
|
||||||
|
preciseTiming: true,
|
||||||
|
showLyricsEvenIfInexact: true,
|
||||||
|
showTimeCodes: false,
|
||||||
|
defaultTextString: '♪',
|
||||||
|
lineEffect: 'scale',
|
||||||
|
} as SyncedLyricsPluginConfig,
|
||||||
|
|
||||||
|
menu,
|
||||||
|
renderer,
|
||||||
|
stylesheets: [style],
|
||||||
|
});
|
||||||
138
src/plugins/synced-lyrics/menu.ts
Normal file
138
src/plugins/synced-lyrics/menu.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { MenuItemConstructorOptions } from 'electron';
|
||||||
|
|
||||||
|
import { MenuContext } from '@/types/contexts';
|
||||||
|
import { SyncedLyricsPluginConfig } from './types';
|
||||||
|
|
||||||
|
export const menu = async ({
|
||||||
|
getConfig,
|
||||||
|
setConfig,
|
||||||
|
}: MenuContext<SyncedLyricsPluginConfig>): Promise<
|
||||||
|
MenuItemConstructorOptions[]
|
||||||
|
> => {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Make the lyrics perfectly synced',
|
||||||
|
toolTip:
|
||||||
|
'Calculate to the milisecond the display of the next line (can have a small impact on performance)',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.preciseTiming,
|
||||||
|
click(item) {
|
||||||
|
setConfig({
|
||||||
|
preciseTiming: item.checked,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Line effect',
|
||||||
|
toolTip: 'Choose the effect to apply to the current line',
|
||||||
|
type: 'submenu',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Scale',
|
||||||
|
toolTip: 'Scale the current line',
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.lineEffect === 'scale',
|
||||||
|
click() {
|
||||||
|
setConfig({
|
||||||
|
lineEffect: 'scale',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Offset',
|
||||||
|
toolTip: 'Offset on the right the current line',
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.lineEffect === 'offset',
|
||||||
|
click() {
|
||||||
|
setConfig({
|
||||||
|
lineEffect: 'offset',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Focus',
|
||||||
|
toolTip: 'Make only the current line white',
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.lineEffect === 'focus',
|
||||||
|
click() {
|
||||||
|
setConfig({
|
||||||
|
lineEffect: 'focus',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Default character between lyrics',
|
||||||
|
toolTip: 'Choose the default string to use for the gap between lyrics',
|
||||||
|
type: 'submenu',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: '♪',
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.defaultTextString === '♪',
|
||||||
|
click() {
|
||||||
|
setConfig({
|
||||||
|
defaultTextString: '♪',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '[SPACE]',
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.defaultTextString === ' ',
|
||||||
|
click() {
|
||||||
|
setConfig({
|
||||||
|
defaultTextString: ' ',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '...',
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.defaultTextString === '...',
|
||||||
|
click() {
|
||||||
|
setConfig({
|
||||||
|
defaultTextString: '...',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '———',
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.defaultTextString === '———',
|
||||||
|
click() {
|
||||||
|
setConfig({
|
||||||
|
defaultTextString: '———',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Show time codes',
|
||||||
|
toolTip: 'Show the time codes next to the lyrics',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.showTimeCodes,
|
||||||
|
click(item) {
|
||||||
|
setConfig({
|
||||||
|
showTimeCodes: item.checked,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Show lyrics even if inexact',
|
||||||
|
toolTip:
|
||||||
|
'If the song is not found, the plugin tries again with a different search query.\nThe result from the second attempt may not be exact.',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.showLyricsEvenIfInexact,
|
||||||
|
click(item) {
|
||||||
|
setConfig({
|
||||||
|
showLyricsEvenIfInexact: item.checked,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
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 { LineLyrics } from '../../types';
|
||||||
|
import {
|
||||||
|
differentDuration,
|
||||||
|
hadSecondAttempt,
|
||||||
|
isFetching,
|
||||||
|
isInstrumental,
|
||||||
|
makeLyricsRequest,
|
||||||
|
} from '../lyrics/fetch';
|
||||||
|
|
||||||
|
export const [debugInfo, setDebugInfo] = createSignal<string>();
|
||||||
|
export const [lineLyrics, setLineLyrics] = createSignal<LineLyrics[]>([]);
|
||||||
|
export const [currentTime, setCurrentTime] = createSignal<number>(-1);
|
||||||
|
|
||||||
|
export const LyricsContainer = () => {
|
||||||
|
const [error, setError] = createSignal('');
|
||||||
|
|
||||||
|
const onRefetch = async () => {
|
||||||
|
if (isFetching()) return;
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const info = getSongInfo();
|
||||||
|
await makeLyricsRequest(info).catch((err) => {
|
||||||
|
setError(`${err}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<Match when={error()}>
|
||||||
|
<yt-formatted-string
|
||||||
|
class="warning-lyrics description ytmusic-description-shelf-renderer"
|
||||||
|
text={{
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: t('plugins.synced-lyrics.errors.fetch'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
53
src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx
Normal file
53
src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { createEffect, createMemo } from 'solid-js';
|
||||||
|
|
||||||
|
import { currentTime } from './LyricsContainer';
|
||||||
|
|
||||||
|
import { config } from '../renderer';
|
||||||
|
import { _ytAPI } from '..';
|
||||||
|
|
||||||
|
import type { LineLyrics } from '../../types';
|
||||||
|
|
||||||
|
interface SyncedLineProps {
|
||||||
|
line: LineLyrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SyncedLine = ({ line }: SyncedLineProps) => {
|
||||||
|
const status = createMemo(() => {
|
||||||
|
const current = currentTime();
|
||||||
|
|
||||||
|
if (line.timeInMs >= current) return 'upcoming';
|
||||||
|
if (current - line.timeInMs >= line.duration) return 'previous';
|
||||||
|
return 'current';
|
||||||
|
});
|
||||||
|
|
||||||
|
let ref: HTMLDivElement;
|
||||||
|
createEffect(() => {
|
||||||
|
if (status() === 'current') {
|
||||||
|
ref.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref!}
|
||||||
|
class={`synced-line ${status()}`}
|
||||||
|
onClick={() => {
|
||||||
|
_ytAPI?.seekTo(line.timeInMs / 1000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<yt-formatted-string
|
||||||
|
class="text-lyrics description ytmusic-description-shelf-renderer"
|
||||||
|
text={{
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `${config()?.showTimeCodes ? `[${line.time}] ` : ''}${line.text}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
90
src/plugins/synced-lyrics/renderer/index.ts
Normal file
90
src/plugins/synced-lyrics/renderer/index.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
/* eslint-disable prefer-const, @typescript-eslint/no-unused-vars */
|
||||||
|
|
||||||
|
import { createRenderer } from '@/utils';
|
||||||
|
import { SongInfo } from '@/providers/song-info';
|
||||||
|
import { YoutubePlayer } from '@/types/youtube-player';
|
||||||
|
|
||||||
|
import { makeLyricsRequest } from './lyrics';
|
||||||
|
import { selectors, tabStates } from './utils';
|
||||||
|
import { setConfig } from './renderer';
|
||||||
|
import { setCurrentTime } from './components/LyricsContainer';
|
||||||
|
|
||||||
|
import type { SyncedLyricsPluginConfig } from '../types';
|
||||||
|
|
||||||
|
export let _ytAPI: YoutubePlayer | null = null;
|
||||||
|
|
||||||
|
export const renderer = createRenderer({
|
||||||
|
onConfigChange(newConfig) {
|
||||||
|
setConfig(newConfig as SyncedLyricsPluginConfig);
|
||||||
|
},
|
||||||
|
|
||||||
|
observerCallback(mutations: MutationRecord[]) {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
const header = mutation.target as HTMLElement;
|
||||||
|
|
||||||
|
switch (mutation.attributeName) {
|
||||||
|
case 'disabled':
|
||||||
|
header.removeAttribute('disabled');
|
||||||
|
break;
|
||||||
|
case 'aria-selected':
|
||||||
|
tabStates[header.ariaSelected as 'true' | 'false']?.(
|
||||||
|
_ytAPI?.getVideoData(),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onPlayerApiReady(api) {
|
||||||
|
_ytAPI = api;
|
||||||
|
|
||||||
|
// @ts-expect-error type is 'unknown', so TS complains
|
||||||
|
api.addEventListener('videodatachange', this.videoDataChange);
|
||||||
|
|
||||||
|
// @ts-expect-error type is 'unknown', so TS complains
|
||||||
|
this.videoDataChange();
|
||||||
|
},
|
||||||
|
|
||||||
|
hasAddedEvents: false,
|
||||||
|
observer: null as MutationObserver | null,
|
||||||
|
videoDataChange() {
|
||||||
|
if (!this.hasAddedEvents) {
|
||||||
|
const video = document.querySelector('video');
|
||||||
|
|
||||||
|
// @ts-expect-error type is 'unknown', so TS complains
|
||||||
|
video?.addEventListener('timeupdate', this.progressCallback);
|
||||||
|
|
||||||
|
if (video) this.hasAddedEvents = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = document.querySelector<HTMLElement>(selectors.head);
|
||||||
|
if (!header) return;
|
||||||
|
|
||||||
|
this.observer ??= new MutationObserver(
|
||||||
|
this.observerCallback as MutationCallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Force the lyrics tab to be enabled at all times.
|
||||||
|
this.observer.disconnect();
|
||||||
|
this.observer.observe(header, { attributes: true });
|
||||||
|
header.removeAttribute('disabled');
|
||||||
|
},
|
||||||
|
|
||||||
|
progressCallback(evt: Event) {
|
||||||
|
switch (evt.type) {
|
||||||
|
case 'timeupdate': {
|
||||||
|
const video = evt.target as HTMLVideoElement;
|
||||||
|
setCurrentTime(video.currentTime * 1000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async start({ getConfig, ipc: { on } }) {
|
||||||
|
setConfig((await getConfig()) as SyncedLyricsPluginConfig);
|
||||||
|
|
||||||
|
on('ytmd:update-song-info', async (info: SongInfo) => {
|
||||||
|
await makeLyricsRequest(info);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
197
src/plugins/synced-lyrics/renderer/lyrics/fetch.ts
Normal file
197
src/plugins/synced-lyrics/renderer/lyrics/fetch.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import { createSignal } from 'solid-js';
|
||||||
|
import { jaroWinkler } from '@skyra/jaro-winkler';
|
||||||
|
|
||||||
|
import { SongInfo } from '@/providers/song-info';
|
||||||
|
|
||||||
|
import { LineLyrics, LRCLIBSearchResponse } from '../../types';
|
||||||
|
import { config } from '../renderer';
|
||||||
|
import { setDebugInfo, setLineLyrics } from '../components/LyricsContainer';
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
export const [isInstrumental, setIsInstrumental] = createSignal(false);
|
||||||
|
// prettier-ignore
|
||||||
|
export const [isFetching, setIsFetching] = createSignal(false);
|
||||||
|
// prettier-ignore
|
||||||
|
export const [hadSecondAttempt, setHadSecondAttempt] = createSignal(false);
|
||||||
|
// prettier-ignore
|
||||||
|
export const [differentDuration, setDifferentDuration] = createSignal(false);
|
||||||
|
// eslint-disable-next-line prefer-const
|
||||||
|
export let foundPlainTextLyrics = false;
|
||||||
|
|
||||||
|
export type SongData = {
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
songDuration: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
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),
|
||||||
|
];
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
const timeInMs = (minutes * 60 * 1000) + (seconds * 1000) + millis;
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
timeInMs,
|
||||||
|
time: `${minutes}:${seconds}:${millis}`,
|
||||||
|
text: text?.trim() ?? config()!.defaultTextString,
|
||||||
|
status: 'upcoming',
|
||||||
|
duration: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const makeLyricsRequest = async (extractedSongInfo: SongInfo) => {
|
||||||
|
setLineLyrics([]);
|
||||||
|
const songData: SongData = {
|
||||||
|
title: `${extractedSongInfo.title}`,
|
||||||
|
artist: `${extractedSongInfo.artist}`,
|
||||||
|
album: `${extractedSongInfo.album}`,
|
||||||
|
songDuration: extractedSongInfo.songDuration,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lyrics = await getLyricsList(songData);
|
||||||
|
setLineLyrics(lyrics ?? []);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLyricsList = async (
|
||||||
|
songData: SongData,
|
||||||
|
): Promise<LineLyrics[] | null> => {
|
||||||
|
setIsFetching(true);
|
||||||
|
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) {
|
||||||
|
setIsFetching(false);
|
||||||
|
setDebugInfo('Got non-OK response from server.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = (await response.json().catch((e: Error) => {
|
||||||
|
setDebugInfo(`Error: ${e.message}\n\n${e.stack}`);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})) as LRCLIBSearchResponse | null;
|
||||||
|
if (!data || !Array.isArray(data)) {
|
||||||
|
setIsFetching(false);
|
||||||
|
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) {
|
||||||
|
setIsFetching(false);
|
||||||
|
setDebugInfo('Got non-OK response from server. (2)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
data = (await response.json()) as LRCLIBSearchResponse;
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
setIsFetching(false);
|
||||||
|
setDebugInfo('Unexpected server response. (2)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHadSecondAttempt(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredResults = [];
|
||||||
|
for (const item of data) {
|
||||||
|
if (!item.syncedLyrics) continue;
|
||||||
|
|
||||||
|
const { artist } = songData;
|
||||||
|
const { artistName } = item;
|
||||||
|
|
||||||
|
const ratio = jaroWinkler(artist.toLowerCase(), artistName.toLowerCase());
|
||||||
|
|
||||||
|
if (ratio <= 0.9) continue;
|
||||||
|
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) {
|
||||||
|
setIsFetching(false);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Separate the lyrics into lines
|
||||||
|
const raw = closestResult.syncedLyrics.split('\n');
|
||||||
|
|
||||||
|
// Add a blank line at the beginning
|
||||||
|
raw.unshift('[0:0.0] ');
|
||||||
|
|
||||||
|
const syncedLyricList = [];
|
||||||
|
|
||||||
|
for (let idx = 0; idx < raw.length; idx++) {
|
||||||
|
const syncedLine = extractTimeAndText(raw[idx], idx);
|
||||||
|
if (syncedLine) {
|
||||||
|
syncedLyricList.push(syncedLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of syncedLyricList) {
|
||||||
|
const next = syncedLyricList[line.index + 1];
|
||||||
|
if (!next) {
|
||||||
|
line.duration = Infinity;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
line.duration = next.timeInMs - line.timeInMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFetching(false);
|
||||||
|
return syncedLyricList;
|
||||||
|
};
|
||||||
45
src/plugins/synced-lyrics/renderer/lyrics/index.ts
Normal file
45
src/plugins/synced-lyrics/renderer/lyrics/index.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/* eslint-disable import/order */
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
21
src/plugins/synced-lyrics/renderer/renderer.tsx
Normal file
21
src/plugins/synced-lyrics/renderer/renderer.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/* eslint-disable import/order */
|
||||||
|
|
||||||
|
import { createSignal, Show } from 'solid-js';
|
||||||
|
import { VideoDetails } from '@/types/video-details';
|
||||||
|
import { SyncedLyricsPluginConfig } from '../types';
|
||||||
|
import { LyricsContainer } from './components/LyricsContainer';
|
||||||
|
|
||||||
|
export const [isVisible, setIsVisible] = createSignal<boolean>(false);
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
export const [config, setConfig] = createSignal<SyncedLyricsPluginConfig | null>(null);
|
||||||
|
// prettier-ignore
|
||||||
|
export const [playerState, setPlayerState] = createSignal<VideoDetails | null>(null);
|
||||||
|
|
||||||
|
export const LyricsRenderer = () => {
|
||||||
|
return (
|
||||||
|
<Show when={isVisible()}>
|
||||||
|
<LyricsContainer />
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
src/plugins/synced-lyrics/renderer/utils.tsx
Normal file
37
src/plugins/synced-lyrics/renderer/utils.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { render } from 'solid-js/web';
|
||||||
|
|
||||||
|
import { LyricsRenderer, setIsVisible, setPlayerState } from './renderer';
|
||||||
|
import { VideoDetails } from '@/types/video-details';
|
||||||
|
|
||||||
|
export const selectors = {
|
||||||
|
head: '#tabsContent > .tab-header:nth-of-type(2)',
|
||||||
|
body: {
|
||||||
|
tabRenderer: '#tab-renderer[page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"]',
|
||||||
|
root: 'ytmusic-description-shelf-renderer',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tabStates = {
|
||||||
|
true: (data?: VideoDetails) => {
|
||||||
|
setIsVisible(true);
|
||||||
|
setPlayerState(data ?? null);
|
||||||
|
|
||||||
|
const tabRenderer = document.querySelector<HTMLElement>(
|
||||||
|
selectors.body.tabRenderer,
|
||||||
|
);
|
||||||
|
if (!tabRenderer) return;
|
||||||
|
|
||||||
|
let container = document.querySelector('#synced-lyrics-container');
|
||||||
|
if (container) return;
|
||||||
|
|
||||||
|
container = Object.assign(document.createElement('div'), {
|
||||||
|
id: 'synced-lyrics-container',
|
||||||
|
});
|
||||||
|
|
||||||
|
tabRenderer.appendChild(container);
|
||||||
|
render(() => <LyricsRenderer />, container);
|
||||||
|
},
|
||||||
|
false: () => {
|
||||||
|
setIsVisible(false);
|
||||||
|
},
|
||||||
|
};
|
||||||
78
src/plugins/synced-lyrics/style.css
Normal file
78
src/plugins/synced-lyrics/style.css
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
/* Hides the original lyrics, to only show our own. */
|
||||||
|
#tab-renderer[page-type='MUSIC_PAGE_TYPE_TRACK_LYRICS'] > * {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tab-renderer[page-type='MUSIC_PAGE_TYPE_TRACK_LYRICS'] > #synced-lyrics-container {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* :root {
|
||||||
|
--ytmusic-text-primary: #fff;
|
||||||
|
--ytmusic-text-secondary: #aaa;
|
||||||
|
} */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--global-margin: 0.7rem;
|
||||||
|
--previous-lyrics: var(--ytmusic-text-primary);
|
||||||
|
--current-lyrics: var(--ytmusic-text-primary);
|
||||||
|
--upcoming-lyrics: var(--ytmusic-text-secondary);
|
||||||
|
--size-lyrics: 1.2em;
|
||||||
|
--offset-lyrics: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyric-container {
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: clamp(1.4rem, 1.1vmax, 3rem) !important;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.synced-line {
|
||||||
|
width: var(--lyric-width, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.synced-line > .text-lyrics {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.synced-lyrics {
|
||||||
|
display: block;
|
||||||
|
justify-content: left;
|
||||||
|
text-align: left;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
margin-right: 20px;
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-lyrics {
|
||||||
|
color: var(--ytmusic-text-secondary) !important;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-lyrics {
|
||||||
|
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;
|
||||||
|
transform-origin: 0 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previous > .text-lyrics {
|
||||||
|
color: var(--previous-lyrics);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current > .text-lyrics {
|
||||||
|
color: var(--current-lyrics);
|
||||||
|
font-weight: bold;
|
||||||
|
scale: var(--size-lyrics);
|
||||||
|
translate: var(--offset-lyrics) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming > .text-lyrics {
|
||||||
|
color: var(--upcoming-lyrics);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
38
src/plugins/synced-lyrics/types.ts
Normal file
38
src/plugins/synced-lyrics/types.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
export type SyncedLyricsPluginConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
preciseTiming: boolean;
|
||||||
|
showTimeCodes: boolean;
|
||||||
|
defaultTextString: string;
|
||||||
|
showLyricsEvenIfInexact: boolean;
|
||||||
|
lineEffect: LineEffect;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LineEffect = 'scale' | 'offset' | 'focus';
|
||||||
|
|
||||||
|
export type LRCLIBSearchResponse = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
trackName: string;
|
||||||
|
artistName: string;
|
||||||
|
albumName: string;
|
||||||
|
duration: number;
|
||||||
|
instrumental: boolean;
|
||||||
|
plainLyrics: string;
|
||||||
|
syncedLyrics: string;
|
||||||
|
}[];
|
||||||
@ -241,7 +241,7 @@ export interface FlagEndpoint {
|
|||||||
flagAction: string;
|
flagAction: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VideoDataChangeValue = Record<string, unknown> & {
|
export type VideoDataChangeValue = {
|
||||||
videoId: string;
|
videoId: string;
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
|||||||
37
src/yt-web-components.d.ts
vendored
Normal file
37
src/yt-web-components.d.ts
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import type { ComponentProps } from 'solid-js';
|
||||||
|
|
||||||
|
declare module 'solid-js' {
|
||||||
|
namespace JSX {
|
||||||
|
interface YtFormattedStringProps {
|
||||||
|
text?: {
|
||||||
|
runs: { text: string }[];
|
||||||
|
};
|
||||||
|
data?: object;
|
||||||
|
disabled?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YtButtonRendererProps {
|
||||||
|
data?: {
|
||||||
|
icon?: {
|
||||||
|
iconType: string;
|
||||||
|
};
|
||||||
|
isDisabled?: boolean;
|
||||||
|
style?: string;
|
||||||
|
text?: {
|
||||||
|
simpleText: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YpYtPaperSpinnerLiteProps {
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntrinsicElements {
|
||||||
|
'yt-formatted-string': ComponentProps<'span'> & YtFormattedStringProps;
|
||||||
|
'yt-button-renderer': ComponentProps<'button'> & YtButtonRendererProps;
|
||||||
|
'tp-yt-paper-spinner-lite': ComponentProps<'div'> & YpYtPaperSpinnerLiteProps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
test-results/.last-run.json
Normal file
6
test-results/.last-run.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": [
|
||||||
|
"3c41ab34e2f6ae47f5cb-f82377181f2146bd94b6"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user