From 23f9bd4e9f454c1761ef93f22432e58219398bb2 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 22 May 2023 17:38:31 -0700 Subject: [PATCH] initial implementation for lyrics --- .../api/jellyfin/jellyfin-normalize.ts | 1 + .../api/navidrome/navidrome-normalize.ts | 1 + .../api/subsonic/subsonic-normalize.ts | 1 + src/renderer/api/types.ts | 1 + src/renderer/features/lyrics/lyric-line.tsx | 20 ++++ src/renderer/features/lyrics/lyrics.tsx | 52 ++++++++ .../features/lyrics/synchronized-lyrics.tsx | 112 ++++++++++++++++++ .../features/lyrics/unsynchronized-lyrics.tsx | 25 ++++ .../components/full-screen-player-queue.tsx | 21 ++-- 9 files changed, 223 insertions(+), 11 deletions(-) create mode 100644 src/renderer/features/lyrics/lyric-line.tsx create mode 100644 src/renderer/features/lyrics/lyrics.tsx create mode 100644 src/renderer/features/lyrics/synchronized-lyrics.tsx create mode 100644 src/renderer/features/lyrics/unsynchronized-lyrics.tsx diff --git a/src/renderer/api/jellyfin/jellyfin-normalize.ts b/src/renderer/api/jellyfin/jellyfin-normalize.ts index 13e1b0fe..60079519 100644 --- a/src/renderer/api/jellyfin/jellyfin-normalize.ts +++ b/src/renderer/api/jellyfin/jellyfin-normalize.ts @@ -155,6 +155,7 @@ const normalizeSong = ( imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }), itemType: LibraryItem.SONG, lastPlayedAt: null, + lyrics: null, name: item.Name, path: (item.MediaSources && item.MediaSources[0]?.Path) || null, playCount: (item.UserData && item.UserData.PlayCount) || 0, diff --git a/src/renderer/api/navidrome/navidrome-normalize.ts b/src/renderer/api/navidrome/navidrome-normalize.ts index de6bdeaa..ea6e0b25 100644 --- a/src/renderer/api/navidrome/navidrome-normalize.ts +++ b/src/renderer/api/navidrome/navidrome-normalize.ts @@ -74,6 +74,7 @@ const normalizeSong = ( imageUrl, itemType: LibraryItem.SONG, lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate, + lyrics: item.lyrics ? item.lyrics : null, name: item.title, path: item.path, playCount: item.playCount, diff --git a/src/renderer/api/subsonic/subsonic-normalize.ts b/src/renderer/api/subsonic/subsonic-normalize.ts index 53e4bc6b..d2e54d54 100644 --- a/src/renderer/api/subsonic/subsonic-normalize.ts +++ b/src/renderer/api/subsonic/subsonic-normalize.ts @@ -81,6 +81,7 @@ const normalizeSong = ( imageUrl, itemType: LibraryItem.SONG, lastPlayedAt: null, + lyrics: null, name: item.title, path: item.path, playCount: item?.playCount || 0, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 4141d319..b39abb33 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -187,6 +187,7 @@ export type Song = { imageUrl: string | null; itemType: LibraryItem.SONG; lastPlayedAt: string | null; + lyrics: string | null; name: string; path: string | null; playCount: number; diff --git a/src/renderer/features/lyrics/lyric-line.tsx b/src/renderer/features/lyrics/lyric-line.tsx new file mode 100644 index 00000000..5d816863 --- /dev/null +++ b/src/renderer/features/lyrics/lyric-line.tsx @@ -0,0 +1,20 @@ +import { ComponentPropsWithoutRef } from 'react'; +import { TextTitle } from '/@/renderer/components/text-title'; + +interface LyricLineProps extends ComponentPropsWithoutRef<'div'> { + active: boolean; + lyric: string; +} + +export const LyricLine = ({ lyric: text, active, ...props }: LyricLineProps) => { + return ( + + {text} + + ); +}; diff --git a/src/renderer/features/lyrics/lyrics.tsx b/src/renderer/features/lyrics/lyrics.tsx new file mode 100644 index 00000000..0870c28c --- /dev/null +++ b/src/renderer/features/lyrics/lyrics.tsx @@ -0,0 +1,52 @@ +import { useMemo } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { ErrorFallback } from '/@/renderer/features/action-required'; +import { useCurrentSong } from '/@/renderer/store'; +import { SynchronizedLyricsArray, SynchronizedLyrics } from './synchronized-lyrics'; +import { UnsynchronizedLyrics } from '/@/renderer/features/lyrics/unsynchronized-lyrics'; + +// use by https://github.com/ustbhuangyi/lyric-parser + +const timeExp = /\[(\d{2,}):(\d{2})(?:\.(\d{2,3}))?]([^\n]+)\n/g; + +export const Lyrics = () => { + const currentSong = useCurrentSong(); + + const lyrics = useMemo(() => { + if (currentSong?.lyrics) { + const originalText = currentSong.lyrics; + console.log(originalText); + + const synchronizedLines = originalText.matchAll(timeExp); + + const synchronizedTimes: SynchronizedLyricsArray = []; + + for (const line of synchronizedLines) { + const [, minute, sec, ms, text] = line; + const minutes = parseInt(minute, 10); + const seconds = parseInt(sec, 10); + const milis = ms.length === 3 ? parseInt(ms, 10) : parseInt(ms, 10) * 10; + + const timeInMilis = (minutes * 60 + seconds) * 1000 + milis; + synchronizedTimes.push([timeInMilis, text]); + } + + if (synchronizedTimes.length === 0) { + return originalText; + } + return synchronizedTimes; + } + return null; + }, [currentSong?.lyrics]); + + return ( + + {lyrics && + (Array.isArray(lyrics) ? ( + + ) : ( + + ))} + + ); +}; diff --git a/src/renderer/features/lyrics/synchronized-lyrics.tsx b/src/renderer/features/lyrics/synchronized-lyrics.tsx new file mode 100644 index 00000000..773913ce --- /dev/null +++ b/src/renderer/features/lyrics/synchronized-lyrics.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCurrentStatus, useCurrentTime } from '/@/renderer/store'; +import { PlayerStatus } from '/@/renderer/types'; +import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; + +export type SynchronizedLyricsArray = Array<[number, string]>; + +interface SynchronizedLyricsProps { + lyrics: SynchronizedLyricsArray; +} + +const CLOSE_ENOUGH_TIME_DIFF_SEC = 0.2; + +export const SynchronizedLyrics = ({ lyrics }: SynchronizedLyricsProps) => { + const [index, setIndex] = useState(-1); + const status = useCurrentStatus(); + const lastTimeUpdate = useRef(Infinity); + const previousTimestamp = useRef(0); + const now = useCurrentTime(); + + const timeout = useRef>(); + + const estimateElapsedTime = useCallback(() => { + const now = new Date().getTime(); + return (now - previousTimestamp.current) / 1000; + }, []); + + const getCurrentLyric = useCallback( + (timeInMs: number) => { + for (let idx = 0; idx < lyrics.length; idx += 1) { + if (timeInMs <= lyrics[idx][0]) { + return idx === 0 ? idx : idx - 1; + } + } + return lyrics.length - 1; + }, + [lyrics], + ); + + const doSetNextTimeout = useCallback( + (idx: number, currentTimeMs: number) => { + if (timeout.current) { + clearTimeout(timeout.current); + } + + document + .querySelector(`#lyric-${idx}`) + ?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setIndex(idx); + + if (idx !== lyrics.length - 1) { + const nextTimeMs = lyrics[idx + 1][0]; + const nextTime = nextTimeMs - currentTimeMs; + + timeout.current = setTimeout(() => { + doSetNextTimeout(idx + 1, nextTimeMs); + }, nextTime); + } else { + timeout.current = undefined; + } + }, + [lyrics], + ); + + const handleTimeChange = useCallback(() => { + const elapsedJs = estimateElapsedTime(); + const elapsedPlayer = now - lastTimeUpdate.current; + + lastTimeUpdate.current = now; + previousTimestamp.current = new Date().getTime(); + + if (Math.abs(elapsedJs - elapsedPlayer) >= CLOSE_ENOUGH_TIME_DIFF_SEC) { + if (timeout.current) { + clearTimeout(timeout.current); + } + + const currentTimeMs = now * 1000; + const idx = getCurrentLyric(currentTimeMs); + doSetNextTimeout(idx, currentTimeMs); + } + }, [doSetNextTimeout, estimateElapsedTime, getCurrentLyric, now]); + + useEffect(() => { + if (status !== PlayerStatus.PLAYING) { + if (timeout.current) { + clearTimeout(timeout.current); + timeout.current = undefined; + } + + return () => {}; + } + + const changeTimeout = setTimeout(() => { + handleTimeChange(); + }, 100); + + return () => clearTimeout(changeTimeout); + }, [handleTimeChange, status]); + + return ( +
+ {lyrics.map(([, text], idx) => ( + + ))} +
+ ); +}; diff --git a/src/renderer/features/lyrics/unsynchronized-lyrics.tsx b/src/renderer/features/lyrics/unsynchronized-lyrics.tsx new file mode 100644 index 00000000..b2783f76 --- /dev/null +++ b/src/renderer/features/lyrics/unsynchronized-lyrics.tsx @@ -0,0 +1,25 @@ +import { useMemo } from 'react'; +import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; + +interface UnsynchronizedLyricsProps { + lyrics: string; +} + +export const UnsynchronizedLyrics = ({ lyrics }: UnsynchronizedLyricsProps) => { + const lines = useMemo(() => { + return lyrics.split('\n'); + }, [lyrics]); + + return ( +
+ {lines.map((text, idx) => ( + + ))} +
+ ); +}; diff --git a/src/renderer/features/player/components/full-screen-player-queue.tsx b/src/renderer/features/player/components/full-screen-player-queue.tsx index 42c438c5..2ab95961 100644 --- a/src/renderer/features/player/components/full-screen-player-queue.tsx +++ b/src/renderer/features/player/components/full-screen-player-queue.tsx @@ -9,6 +9,7 @@ import { useFullScreenPlayerStore, useFullScreenPlayerStoreActions, } from '/@/renderer/store/full-screen-player.store'; +import { Lyrics } from '/@/renderer/features/lyrics/lyrics'; const QueueContainer = styled.div` position: relative; @@ -26,6 +27,12 @@ const QueueContainer = styled.div` } `; +const LyricsContainer = styled.div` + height: 100%; + overflow: scroll; + text-align: center; +`; + const ActiveTabIndicator = styled(motion.div)` position: absolute; bottom: 0; @@ -115,17 +122,9 @@ export const FullScreenPlayerQueue = () => { ) : activeTab === 'lyrics' ? ( -
- - - - COMING SOON - - -
+ + + ) : null} );