diff --git a/electron.vite.config.mts b/electron.vite.config.mts index a5e94551f8..3229965c7d 100644 --- a/electron.vite.config.mts +++ b/electron.vite.config.mts @@ -1,17 +1,17 @@ import { resolve, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { UserConfig } from 'vite'; import { defineConfig, defineViteConfig } from 'electron-vite'; import builtinModules from 'builtin-modules'; import viteResolve from 'vite-plugin-resolve'; import Inspect from 'vite-plugin-inspect'; +import solidPlugin from 'vite-plugin-solid'; import { pluginVirtualModuleGenerator } from './vite-plugins/plugin-importer.mjs'; import pluginLoader from './vite-plugins/plugin-loader.mjs'; -import type { UserConfig } from 'vite'; import { i18nImporter } from './vite-plugins/i18n-importer.mjs'; -import solidPlugin from 'vite-plugin-solid'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -52,7 +52,10 @@ export default defineConfig({ if (mode === 'development') { commonConfig.plugins?.push( - Inspect({ build: true, outputDir: join(__dirname, '.vite-inspect/backend') }), + Inspect({ + build: true, + outputDir: join(__dirname, '.vite-inspect/backend'), + }), ); return commonConfig; } @@ -96,7 +99,10 @@ export default defineConfig({ if (mode === 'development') { commonConfig.plugins?.push( - Inspect({ build: true, outputDir: join(__dirname, '.vite-inspect/preload') }), + Inspect({ + build: true, + outputDir: join(__dirname, '.vite-inspect/preload'), + }), ); return commonConfig; } @@ -143,7 +149,10 @@ export default defineConfig({ if (mode === 'development') { commonConfig.plugins?.push( - Inspect({ build: true, outputDir: join(__dirname, '.vite-inspect/renderer') }), + Inspect({ + build: true, + outputDir: join(__dirname, '.vite-inspect/renderer'), + }), ); return commonConfig; } diff --git a/package.json b/package.json index 2b5a196811..8de87bf949 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ "@foobar404/wave": "2.0.5", "@jellybrick/electron-better-web-request": "1.0.4", "@jellybrick/mpris-service": "2.1.4", + "@skyra/jaro-winkler": "^1.1.1", "@xhayper/discord-rpc": "1.1.4", "async-mutex": "0.5.0", "butterchurn": "3.0.0-beta.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25455ef167..de715dd58e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: '@jellybrick/mpris-service': specifier: 2.1.4 version: 2.1.4 + '@skyra/jaro-winkler': + specifier: ^1.1.1 + version: 1.1.1 '@xhayper/discord-rpc': specifier: 1.1.4 version: 1.1.4(patch_hash=n7icacbfxuqlodunyqwwt5lccm) @@ -990,6 +993,10 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@skyra/jaro-winkler@1.1.1': + resolution: {integrity: sha512-jT2OWwpajtXTb6opnaIwmBTMpQtKUwl2Ro1zApxIIrpZJon71kZIv6GZSc08LzKO2lpTqUjvD+i7Z2hGuG42KQ==} + engines: {node: '>=v18'} + '@solid-primitives/refs@1.0.6': resolution: {integrity: sha512-ruh4YdVMxThEVnvqbpeLXKojW442vpFU8q7dSKtElGOTa31aKOAkRb9BTbdaTwVjN4BEq79fiiYIXozJNl4dSw==} peerDependencies: @@ -4664,6 +4671,8 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@skyra/jaro-winkler@1.1.1': {} + '@solid-primitives/refs@1.0.6(solid-js@1.8.19)': dependencies: '@solid-primitives/utils': 6.2.2(solid-js@1.8.19) @@ -4984,7 +4993,7 @@ snapshots: 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: '@develar/schema-utils': 2.6.5 '@electron/notarize': 2.2.1 @@ -4998,7 +5007,7 @@ snapshots: builder-util-runtime: 9.2.4 chromium-pickle-js: 0.2.0 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 electron-builder-squirrel-windows: 24.13.3(dmg-builder@24.13.3) electron-publish: 24.13.1 @@ -5630,9 +5639,9 @@ snapshots: 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: - 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-runtime: 9.2.4 fs-extra: 10.1.0 @@ -5704,7 +5713,7 @@ snapshots: electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3): 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 builder-util: 24.13.1 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)): 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-runtime: 9.2.4 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 is-ci: 3.0.1 lazy-val: 1.0.5 diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 7cee2e74b2..d0c77234e3 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -418,19 +418,19 @@ "presets": "Presets", "skip-existing": "Skip existing files", "download-finish-settings": { - "label": "Download on finish", - "submenu": { - "enabled": "Enabled", - "mode": "Time mode", - "seconds": "Seconds", - "percent": "Percent", - "advanced": "Advanced" - }, - "prompt": { - "title": "Configure when to download", - "last-seconds": "Last x seconds", - "last-percent": "After x percent" - } + "label": "Download on finish", + "submenu": { + "enabled": "Enabled", + "mode": "Time mode", + "seconds": "Seconds", + "percent": "Percent", + "advanced": "Advanced" + }, + "prompt": { + "title": "Configure when to download", + "last-seconds": "Last x seconds", + "last-percent": "After x percent" + } } }, "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", "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": { "description": "Control playback from your Windows taskbar", "name": "Taskbar Media Control" diff --git a/src/plugins/lyrics-genius/renderer.ts b/src/plugins/lyrics-genius/renderer.ts index 4f54b42e2d..23f66422b6 100644 --- a/src/plugins/lyrics-genius/renderer.ts +++ b/src/plugins/lyrics-genius/renderer.ts @@ -77,6 +77,7 @@ export const onRendererLoad = ({ applyLyricsTabState(); } }; + const applyLyricsTabState = () => { if (lyrics) { tabs.lyrics.removeAttribute('disabled'); @@ -86,6 +87,7 @@ export const onRendererLoad = ({ tabs.lyrics.setAttribute('aria-disabled', ''); } }; + const lyricsTabHandler = () => { const tabContainer = document.querySelector('ytmusic-tab-renderer'); if (!tabContainer) return; diff --git a/src/plugins/synced-lyrics/index.ts b/src/plugins/synced-lyrics/index.ts new file mode 100644 index 0000000000..6b3bd800a8 --- /dev/null +++ b/src/plugins/synced-lyrics/index.ts @@ -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], +}); diff --git a/src/plugins/synced-lyrics/menu.ts b/src/plugins/synced-lyrics/menu.ts new file mode 100644 index 0000000000..ac91ad0543 --- /dev/null +++ b/src/plugins/synced-lyrics/menu.ts @@ -0,0 +1,138 @@ +import { MenuItemConstructorOptions } from 'electron'; + +import { MenuContext } from '@/types/contexts'; +import { SyncedLyricsPluginConfig } from './types'; + +export const menu = async ({ + getConfig, + setConfig, +}: MenuContext): 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, + }); + }, + }, + ]; +}; diff --git a/src/plugins/synced-lyrics/renderer/components/LyricsContainer.tsx b/src/plugins/synced-lyrics/renderer/components/LyricsContainer.tsx new file mode 100644 index 0000000000..b97704d1e8 --- /dev/null +++ b/src/plugins/synced-lyrics/renderer/components/LyricsContainer.tsx @@ -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(); +export const [lineLyrics, setLineLyrics] = createSignal([]); +export const [currentTime, setCurrentTime] = createSignal(-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 ( +
+ + +
+ +
+
+ + + +
+ + + + + + + + } + > + + + + + + + + + + + + {(item) => } + + +
+ ); +}; diff --git a/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx new file mode 100644 index 0000000000..2c54bfebcf --- /dev/null +++ b/src/plugins/synced-lyrics/renderer/components/SyncedLine.tsx @@ -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 ( +
{ + _ytAPI?.seekTo(line.timeInMs / 1000); + }} + > + +
+ ); +}; diff --git a/src/plugins/synced-lyrics/renderer/index.ts b/src/plugins/synced-lyrics/renderer/index.ts new file mode 100644 index 0000000000..92dffb06ab --- /dev/null +++ b/src/plugins/synced-lyrics/renderer/index.ts @@ -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(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); + }); + }, +}); diff --git a/src/plugins/synced-lyrics/renderer/lyrics/fetch.ts b/src/plugins/synced-lyrics/renderer/lyrics/fetch.ts new file mode 100644 index 0000000000..5f281da154 --- /dev/null +++ b/src/plugins/synced-lyrics/renderer/lyrics/fetch.ts @@ -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 => { + 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; +}; diff --git a/src/plugins/synced-lyrics/renderer/lyrics/index.ts b/src/plugins/synced-lyrics/renderer/lyrics/index.ts new file mode 100644 index 0000000000..e47c3aab8f --- /dev/null +++ b/src/plugins/synced-lyrics/renderer/lyrics/index.ts @@ -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; + } +}); diff --git a/src/plugins/synced-lyrics/renderer/renderer.tsx b/src/plugins/synced-lyrics/renderer/renderer.tsx new file mode 100644 index 0000000000..0d3fd7bf9e --- /dev/null +++ b/src/plugins/synced-lyrics/renderer/renderer.tsx @@ -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(false); + +// prettier-ignore +export const [config, setConfig] = createSignal(null); +// prettier-ignore +export const [playerState, setPlayerState] = createSignal(null); + +export const LyricsRenderer = () => { + return ( + + + + ); +}; diff --git a/src/plugins/synced-lyrics/renderer/utils.tsx b/src/plugins/synced-lyrics/renderer/utils.tsx new file mode 100644 index 0000000000..ce916be434 --- /dev/null +++ b/src/plugins/synced-lyrics/renderer/utils.tsx @@ -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( + 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(() => , container); + }, + false: () => { + setIsVisible(false); + }, +}; diff --git a/src/plugins/synced-lyrics/style.css b/src/plugins/synced-lyrics/style.css new file mode 100644 index 0000000000..8795713eb8 --- /dev/null +++ b/src/plugins/synced-lyrics/style.css @@ -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; +} diff --git a/src/plugins/synced-lyrics/types.ts b/src/plugins/synced-lyrics/types.ts new file mode 100644 index 0000000000..0d826b273d --- /dev/null +++ b/src/plugins/synced-lyrics/types.ts @@ -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; +}[]; diff --git a/src/types/player-api-events.ts b/src/types/player-api-events.ts index fe52a80807..b4c3ac04db 100644 --- a/src/types/player-api-events.ts +++ b/src/types/player-api-events.ts @@ -241,7 +241,7 @@ export interface FlagEndpoint { flagAction: string; } -export type VideoDataChangeValue = Record & { +export type VideoDataChangeValue = { videoId: string; title: string; author: string; diff --git a/src/yt-web-components.d.ts b/src/yt-web-components.d.ts new file mode 100644 index 0000000000..5588898ac4 --- /dev/null +++ b/src/yt-web-components.d.ts @@ -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; + } + } +} diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000000..20d521ef08 --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,6 @@ +{ + "status": "failed", + "failedTests": [ + "3c41ab34e2f6ae47f5cb-f82377181f2146bd94b6" + ] +} \ No newline at end of file