From 58f38b26557fc82d872b4b5e046bcfbd43c714f2 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Fri, 2 Jun 2023 23:54:34 -0700 Subject: [PATCH] add jellyfin, improvements --- src/renderer/api/controller.ts | 13 +++++ src/renderer/api/jellyfin/jellyfin-api.ts | 8 ++++ .../api/jellyfin/jellyfin-controller.ts | 25 ++++++++++ src/renderer/api/jellyfin/jellyfin-types.ts | 10 ++++ src/renderer/api/query-keys.ts | 5 ++ src/renderer/api/types.ts | 10 ++++ src/renderer/features/lyrics/lyrics.tsx | 47 +++++++++++++++---- .../features/lyrics/queries/lyric-query.ts | 25 ++++++++++ .../features/lyrics/synchronized-lyrics.tsx | 16 ++++--- .../components/playback/lyric-settings.tsx | 24 +++++++++- src/renderer/store/settings.store.ts | 2 + 11 files changed, 168 insertions(+), 17 deletions(-) create mode 100644 src/renderer/features/lyrics/queries/lyric-query.ts diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index ffea7de0..5271f144 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -46,6 +46,8 @@ import type { AuthenticationResponse, SearchArgs, SearchResponse, + LyricsArgs, + SynchronizedLyricsArray, } from '/@/renderer/api/types'; import { ServerType } from '/@/renderer/types'; import { DeletePlaylistResponse, RandomSongListArgs } from './types'; @@ -76,6 +78,7 @@ export type ControllerEndpoint = Partial<{ getFolderList: () => void; getFolderSongs: () => void; getGenreList: (args: GenreListArgs) => Promise; + getLyrics: (args: LyricsArgs) => Promise; getMusicFolderList: (args: MusicFolderListArgs) => Promise; getPlaylistDetail: (args: PlaylistDetailArgs) => Promise; getPlaylistList: (args: PlaylistListArgs) => Promise; @@ -119,6 +122,7 @@ const endpoints: ApiController = { getFolderList: undefined, getFolderSongs: undefined, getGenreList: jfController.getGenreList, + getLyrics: jfController.getLyrics, getMusicFolderList: jfController.getMusicFolderList, getPlaylistDetail: jfController.getPlaylistDetail, getPlaylistList: jfController.getPlaylistList, @@ -154,6 +158,7 @@ const endpoints: ApiController = { getFolderList: undefined, getFolderSongs: undefined, getGenreList: ndController.getGenreList, + getLyrics: undefined, getMusicFolderList: ssController.getMusicFolderList, getPlaylistDetail: ndController.getPlaylistDetail, getPlaylistList: ndController.getPlaylistList, @@ -188,6 +193,7 @@ const endpoints: ApiController = { getFolderList: undefined, getFolderSongs: undefined, getGenreList: undefined, + getLyrics: undefined, getMusicFolderList: ssController.getMusicFolderList, getPlaylistDetail: undefined, getPlaylistList: undefined, @@ -448,6 +454,12 @@ const getRandomSongList = async (args: RandomSongListArgs) => { )?.(args); }; +const getLyrics = async (args: LyricsArgs) => { + return ( + apiController('getLyrics', args.apiClientProps.server?.type) as ControllerEndpoint['getLyrics'] + )?.(args); +}; + export const controller = { addToPlaylist, authenticate, @@ -461,6 +473,7 @@ export const controller = { getAlbumList, getArtistList, getGenreList, + getLyrics, getMusicFolderList, getPlaylistDetail, getPlaylistList, diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index 36f7688b..57e3b602 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -174,6 +174,14 @@ export const contract = c.router({ 400: jfType._response.error, }, }, + getSongLyrics: { + method: 'GET', + path: 'users/:userId/Items/:id/Lyrics', + responses: { + 200: jfType._response.lyrics, + 404: jfType._response.error, + }, + }, getTopSongsList: { method: 'GET', path: 'users/:userId/items', diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index fac13278..81f1b144 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -44,6 +44,8 @@ import { SearchResponse, RandomSongListResponse, RandomSongListArgs, + LyricsArgs, + SynchronizedLyricsArray, } from '/@/renderer/api/types'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { jfNormalize } from './jellyfin-normalize'; @@ -846,6 +848,28 @@ const getRandomSongList = async (args: RandomSongListArgs): Promise => { + const { query, apiClientProps } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + const res = await jfApiClient(apiClientProps).getSongLyrics({ + params: { + id: query.songId, + userId: apiClientProps.server?.userId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get lyrics'); + } + + return res.body.Lyrics.map((lyric) => [lyric.Start / 1e4, lyric.Text]); +}; + export const jfController = { addToPlaylist, authenticate, @@ -859,6 +883,7 @@ export const jfController = { getAlbumList, getArtistList, getGenreList, + getLyrics, getMusicFolderList, getPlaylistDetail, getPlaylistList, diff --git a/src/renderer/api/jellyfin/jellyfin-types.ts b/src/renderer/api/jellyfin/jellyfin-types.ts index 0ddc1e05..374e3978 100644 --- a/src/renderer/api/jellyfin/jellyfin-types.ts +++ b/src/renderer/api/jellyfin/jellyfin-types.ts @@ -631,6 +631,15 @@ const searchParameters = paginationParameters.merge(baseParameters); const search = z.any(); +const lyricText = z.object({ + Start: z.number(), + Text: z.string(), +}); + +const lyrics = z.object({ + Lyrics: z.array(lyricText), +}); + export const jfType = { _enum: { collection: jfCollection, @@ -670,6 +679,7 @@ export const jfType = { favorite, genre, genreList, + lyrics, musicFolderList, playlist, playlistList, diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 26f56039..c22ac635 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -14,6 +14,7 @@ import type { SearchQuery, SongDetailQuery, RandomSongListQuery, + LyricsQuery, } from './types'; export const queryKeys: Record< @@ -102,6 +103,10 @@ export const queryKeys: Record< if (query) return [serverId, 'songs', 'list', query] as const; return [serverId, 'songs', 'list'] as const; }, + lyrics: (serverId: string, query?: LyricsQuery) => { + if (query) return [serverId, 'song', 'lyrics', query] as const; + return [serverId, 'song', 'lyrics'] as const; + }, randomSongList: (serverId: string, query?: RandomSongListQuery) => { if (query) return [serverId, 'songs', 'randomSongList', query] as const; return [serverId, 'songs', 'randomSongList'] as const; diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index b39abb33..5c206aae 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1017,6 +1017,16 @@ export type RandomSongListArgs = { export type RandomSongListResponse = SongListResponse; +export type LyricsQuery = { + songId: string; +}; + +export type LyricsArgs = { + query: LyricsQuery; +} & BaseEndpointArgs; + +export type SynchronizedLyricsArray = Array<[number, string]>; + export const instanceOfCancellationError = (error: any) => { return 'revert' in error; }; diff --git a/src/renderer/features/lyrics/lyrics.tsx b/src/renderer/features/lyrics/lyrics.tsx index ae92122b..f41a022c 100644 --- a/src/renderer/features/lyrics/lyrics.tsx +++ b/src/renderer/features/lyrics/lyrics.tsx @@ -3,9 +3,14 @@ import isElectron from 'is-electron'; import { ErrorBoundary } from 'react-error-boundary'; import { ErrorFallback } from '/@/renderer/features/action-required'; import { useCurrentServer, useCurrentSong } from '/@/renderer/store'; -import { SynchronizedLyricsArray, SynchronizedLyrics } from './synchronized-lyrics'; +import { SynchronizedLyrics } from './synchronized-lyrics'; import { UnsynchronizedLyrics } from '/@/renderer/features/lyrics/unsynchronized-lyrics'; import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; +import { Center, Group } from '@mantine/core'; +import { RiInformationFill } from 'react-icons/ri'; +import { TextTitle } from '/@/renderer/components'; +import { SynchronizedLyricsArray } from '/@/renderer/api/types'; +import { useSongLyrics } from '/@/renderer/features/lyrics/queries/lyric-query'; const lyrics = isElectron() ? window.electron.lyrics : null; @@ -22,6 +27,11 @@ export const Lyrics = () => { const [source, setSource] = useState(null); const [songLyrics, setSongLyrics] = useState(null); + const remoteLyrics = useSongLyrics({ + query: { songId: currentSong?.id ?? '' }, + serverId: currentServer?.id, + }); + const songRef = useRef(null); useEffect(() => { @@ -38,7 +48,7 @@ export const Lyrics = () => { }, []); useEffect(() => { - if (currentSong && !currentSong.lyrics) { + if (currentSong && !currentSong.lyrics && !remoteLyrics.isLoading && !remoteLyrics.isSuccess) { lyrics?.fetchLyrics(currentSong); } @@ -46,7 +56,7 @@ export const Lyrics = () => { setOverride(null); setSource(null); - }, [currentSong]); + }, [currentSong, remoteLyrics.isLoading, remoteLyrics.isSuccess]); useEffect(() => { let lyrics: string | null = null; @@ -57,6 +67,10 @@ export const Lyrics = () => { setSource(currentServer?.name ?? 'music server'); } else if (override) { lyrics = override; + } else if (remoteLyrics.isSuccess) { + setSource(currentServer?.name ?? 'music server'); + setSongLyrics(remoteLyrics.data!); + return; } if (lyrics) { @@ -82,16 +96,23 @@ export const Lyrics = () => { } else { setSongLyrics(null); } - }, [currentServer?.name, currentSong, override]); + }, [currentServer?.name, currentSong, override, remoteLyrics.data, remoteLyrics.isSuccess]); return ( - {songLyrics && - (Array.isArray(songLyrics) ? ( - - ) : ( - - ))} + {!songLyrics && ( +
+ + + + No lyrics found + + +
+ )} {source && ( { text={`Provided by: ${source}`} /> )} + {songLyrics && + (Array.isArray(songLyrics) ? ( + + ) : ( + + ))}
); }; diff --git a/src/renderer/features/lyrics/queries/lyric-query.ts b/src/renderer/features/lyrics/queries/lyric-query.ts new file mode 100644 index 00000000..8db081a7 --- /dev/null +++ b/src/renderer/features/lyrics/queries/lyric-query.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; +import { LyricsQuery } from '/@/renderer/api/types'; +import { QueryHookArgs } from '/@/renderer/lib/react-query'; +import { getServerById } from '/@/renderer/store'; +import { controller } from '/@/renderer/api/controller'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { ServerType } from '/@/renderer/types'; + +export const useSongLyrics = (args: QueryHookArgs) => { + const { query, serverId } = args; + const server = getServerById(serverId); + + return useQuery({ + // Note: This currently fetches for every song, even if it shouldn't have + // lyrics, because for some reason HasLyrics is not exposed. Thus, ignore the error + onError: () => {}, + queryFn: ({ signal }) => { + if (!server) throw new Error('Server not found'); + // This should only be called for Jellyfin. Return null to ignore errors + if (server.type !== ServerType.JELLYFIN) return null; + return controller.getLyrics({ apiClientProps: { server, signal }, query }); + }, + queryKey: queryKeys.songs.lyrics(server?.id || '', query), + }); +}; diff --git a/src/renderer/features/lyrics/synchronized-lyrics.tsx b/src/renderer/features/lyrics/synchronized-lyrics.tsx index 191997d2..9d2bc724 100644 --- a/src/renderer/features/lyrics/synchronized-lyrics.tsx +++ b/src/renderer/features/lyrics/synchronized-lyrics.tsx @@ -10,11 +10,10 @@ import { PlaybackType, PlayerStatus } from '/@/renderer/types'; import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; import isElectron from 'is-electron'; import { PlayersRef } from '/@/renderer/features/player/ref/players-ref'; +import { SynchronizedLyricsArray } from '/@/renderer/api/types'; const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; -export type SynchronizedLyricsArray = Array<[number, string]>; - interface SynchronizedLyricsProps { lyrics: SynchronizedLyricsArray; } @@ -40,7 +39,12 @@ export const SynchronizedLyrics = ({ lyrics }: SynchronizedLyricsProps) => { // whether to proceed or stop const timerEpoch = useRef(0); - const followRef = useRef(settings.follow); + const delayMsRef = useRef(settings.delayMs); + const followRef = useRef(settings.follow); + + useEffect(() => { + delayMsRef.current = settings.delayMs; + }, [settings.delayMs]); useEffect(() => { // Copy the follow settings into a ref that can be accessed in the timeout @@ -127,7 +131,7 @@ export const SynchronizedLyrics = ({ lyrics }: SynchronizedLyricsProps) => { } if (index !== lyricRef.current!.length - 1) { - const [nextTime] = lyricRef.current![index + 1]; + const nextTime = lyricRef.current![index + 1][0]; const elapsed = performance.now() - start; @@ -149,7 +153,7 @@ export const SynchronizedLyrics = ({ lyrics }: SynchronizedLyricsProps) => { return false; } - setCurrentLyric(timeInSec * 1000); + setCurrentLyric(timeInSec * 1000 + delayMsRef.current); return true; }) @@ -185,7 +189,7 @@ export const SynchronizedLyrics = ({ lyrics }: SynchronizedLyricsProps) => { clearTimeout(lyricTimer.current); } - setCurrentLyric(now * 1000); + setCurrentLyric(now * 1000 + delayMsRef.current); }, [now, seeked, setCurrentLyric, status]); useEffect(() => { diff --git a/src/renderer/features/settings/components/playback/lyric-settings.tsx b/src/renderer/features/settings/components/playback/lyric-settings.tsx index eebac868..c495f322 100644 --- a/src/renderer/features/settings/components/playback/lyric-settings.tsx +++ b/src/renderer/features/settings/components/playback/lyric-settings.tsx @@ -1,4 +1,4 @@ -import { Switch } from '@mantine/core'; +import { NumberInput, Switch } from '@mantine/core'; import { SettingOption, SettingsSection, @@ -82,6 +82,28 @@ export const LyricSettings = () => { isHidden: !isElectron(), title: 'Providers to fetch music', }, + { + control: ( + { + const value = Number(e.currentTarget.value); + setSettings({ + lyrics: { + ...settings, + delayMs: value, + }, + }); + }} + /> + ), + description: + 'Lyric offset (in milliseconds). Positive values mean that lyrics are shown later, and negative mean that lyrics are shown earlier', + isHidden: !isElectron(), + title: 'Lyric offset', + }, ]; return ; diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 10997a3f..1ab0cbe6 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -123,6 +123,7 @@ export interface SettingsState { globalMediaHotkeys: boolean; }; lyrics: { + delayMs: number; fetch: boolean; follow: boolean; sources: LyricSource[]; @@ -209,6 +210,7 @@ const initialState: SettingsState = { globalMediaHotkeys: true, }, lyrics: { + delayMs: 0, fetch: false, follow: true, sources: [],