From 73cd64748635e1aa0cdbc433884a2c10ae883e57 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Thu, 1 Feb 2024 23:53:10 -0800 Subject: [PATCH] os lyrics --- src/renderer/api/controller.ts | 16 ++++ src/renderer/api/subsonic/subsonic-api.ts | 8 ++ .../api/subsonic/subsonic-controller.ts | 48 ++++++++++++ src/renderer/api/subsonic/subsonic-types.ts | 28 +++++++ src/renderer/api/types.ts | 32 +++++--- src/renderer/features/lyrics/lyrics.tsx | 76 +++++++++++-------- .../features/lyrics/queries/lyric-query.ts | 16 +++- .../features/lyrics/synchronized-lyrics.tsx | 2 +- .../features/lyrics/unsynchronized-lyrics.tsx | 2 +- 9 files changed, 182 insertions(+), 46 deletions(-) diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 73d0039d..6cd4d623 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -50,6 +50,8 @@ import type { LyricsResponse, ServerInfo, ServerInfoArgs, + StructuredLyricsArgs, + StructuredLyric, } from '/@/renderer/api/types'; import { ServerType } from '/@/renderer/types'; import { DeletePlaylistResponse, RandomSongListArgs } from './types'; @@ -90,6 +92,7 @@ export type ControllerEndpoint = Partial<{ getServerInfo: (args: ServerInfoArgs) => Promise; getSongDetail: (args: SongDetailArgs) => Promise; getSongList: (args: SongListArgs) => Promise; + getStructuredLyrics: (args: StructuredLyricsArgs) => Promise; getTopSongs: (args: TopSongListArgs) => Promise; getUserList: (args: UserListArgs) => Promise; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; @@ -135,6 +138,7 @@ const endpoints: ApiController = { getServerInfo: jfController.getServerInfo, getSongDetail: jfController.getSongDetail, getSongList: jfController.getSongList, + getStructuredLyrics: undefined, getTopSongs: jfController.getTopSongList, getUserList: undefined, removeFromPlaylist: jfController.removeFromPlaylist, @@ -172,6 +176,7 @@ const endpoints: ApiController = { getServerInfo: ssController.getServerInfo, getSongDetail: ndController.getSongDetail, getSongList: ndController.getSongList, + getStructuredLyrics: ssController.getStructuredLyrics, getTopSongs: ssController.getTopSongList, getUserList: ndController.getUserList, removeFromPlaylist: ndController.removeFromPlaylist, @@ -206,6 +211,7 @@ const endpoints: ApiController = { getServerInfo: ssController.getServerInfo, getSongDetail: undefined, getSongList: undefined, + getStructuredLyrics: ssController.getStructuredLyrics, getTopSongs: ssController.getTopSongList, getUserList: undefined, scrobble: ssController.scrobble, @@ -496,6 +502,15 @@ const getServerInfo = async (args: ServerInfoArgs) => { )?.(args); }; +const getStructuredLyrics = async (args: StructuredLyricsArgs) => { + return ( + apiController( + 'getStructuredLyrics', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getStructuredLyrics'] + )?.(args); +}; + export const controller = { addToPlaylist, authenticate, @@ -518,6 +533,7 @@ export const controller = { getServerInfo, getSongDetail, getSongList, + getStructuredLyrics, getTopSongList, getUserList, removeFromPlaylist, diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index d3beac24..75757517 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -57,6 +57,14 @@ export const contract = c.router({ 200: ssType._response.serverInfo, }, }, + getStructuredLyrics: { + method: 'GET', + path: 'getLyricsBySongId.view', + query: ssType._parameters.structuredLyrics, + responses: { + 200: ssType._response.structuredLyrics, + }, + }, getTopSongsList: { method: 'GET', path: 'getTopSongs.view', diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 352b5d9a..875b971e 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -23,6 +23,8 @@ import { RandomSongListArgs, ServerInfo, ServerInfoArgs, + StructuredLyricsArgs, + StructuredLyric, } from '/@/renderer/api/types'; import { randomString } from '/@/renderer/utils'; @@ -397,6 +399,51 @@ const getServerInfo = async (args: ServerInfoArgs): Promise => { return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion }; }; +export const getStructuredLyrics = async ( + args: StructuredLyricsArgs, +): Promise => { + const { query, apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).getStructuredLyrics({ + query: { + id: query.songId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get server extensions'); + } + + const lyrics = res.body.lyricsList?.structuredLyrics; + + if (!lyrics) { + return []; + } + + return lyrics.map((lyric) => { + const baseLyric = { + artist: lyric.displayArtist || '', + lang: lyric.lang, + name: lyric.displayTitle || '', + remote: false, + source: apiClientProps.server?.name || 'music server', + }; + + if (lyric.synced) { + return { + ...baseLyric, + lyrics: lyric.line.map((line) => [line.start!, line.value]), + synced: true, + }; + } + return { + ...baseLyric, + lyrics: lyric.line.map((line) => [line.value]).join('\n'), + synced: false, + }; + }); +}; + export const ssController = { authenticate, createFavorite, @@ -404,6 +451,7 @@ export const ssController = { getMusicFolderList, getRandomSongList, getServerInfo, + getStructuredLyrics, getTopSongList, removeFavorite, scrobble, diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index 5999d986..9005fe8c 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -221,6 +221,32 @@ const serverInfo = z.object({ openSubsonicExtensions: z.array(extension), }); +const structuredLyricsParameters = z.object({ + id: z.string(), +}); + +const lyricLine = z.object({ + start: z.number().optional(), + value: z.string(), +}); + +const structuredLyric = z.object({ + displayArtist: z.string().optional(), + displayTitle: z.string().optional(), + lang: z.string(), + line: z.array(lyricLine), + offset: z.number().optional(), + synced: z.boolean(), +}); + +const structuredLyrics = z.object({ + lyricsList: z + .object({ + structuredLyrics: z.array(structuredLyric).optional(), + }) + .optional(), +}); + export const ssType = { _parameters: { albumList: albumListParameters, @@ -232,6 +258,7 @@ export const ssType = { scrobble: scrobbleParameters, search3: search3Parameters, setRating: setRatingParameters, + structuredLyrics: structuredLyricsParameters, topSongsList: topSongsListParameters, }, _response: { @@ -252,6 +279,7 @@ export const ssType = { serverInfo, setRating, song, + structuredLyrics, topSongsList, }, }; diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index ec8ce685..7241d82e 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1092,17 +1092,11 @@ export type InternetProviderLyricSearchResponse = { source: LyricSource; }; -export type SynchronizedLyricMetadata = { - lyrics: SynchronizedLyricsArray; +export type FullLyricsMetadata = { + lyrics: LyricsResponse; remote: boolean; -} & Omit; - -export type UnsynchronizedLyricMetadata = { - lyrics: string; - remote: boolean; -} & Omit; - -export type FullLyricsMetadata = SynchronizedLyricMetadata | UnsynchronizedLyricMetadata; + source: string; +} & Omit; export type LyricOverride = Omit; @@ -1153,3 +1147,21 @@ export type ServerInfo = { id?: string; version: string; }; + +export type StructuredLyricsArgs = { + query: LyricsQuery; +} & BaseEndpointArgs; + +export type StructuredUnsyncedLyric = { + lyrics: string; + synced: false; +} & Omit; + +export type StructuredSyncedLyric = { + lyrics: SynchronizedLyricsArray; + synced: true; +} & Omit; + +export type StructuredLyric = { + lang: string; +} & (StructuredUnsyncedLyric | StructuredSyncedLyric); diff --git a/src/renderer/features/lyrics/lyrics.tsx b/src/renderer/features/lyrics/lyrics.tsx index 31ae97a0..111b50d1 100644 --- a/src/renderer/features/lyrics/lyrics.tsx +++ b/src/renderer/features/lyrics/lyrics.tsx @@ -1,21 +1,19 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Center, Group } from '@mantine/core'; import { AnimatePresence, motion } from 'framer-motion'; import { ErrorBoundary } from 'react-error-boundary'; import { RiInformationFill } from 'react-icons/ri'; import styled from 'styled-components'; import { useSongLyricsByRemoteId, useSongLyricsBySong } from './queries/lyric-query'; -import { SynchronizedLyrics } from './synchronized-lyrics'; -import { Spinner, TextTitle } from '/@/renderer/components'; +import { SynchronizedLyrics, SynchronizedLyricsProps } from './synchronized-lyrics'; +import { Select, Spinner, TextTitle } from '/@/renderer/components'; import { ErrorFallback } from '/@/renderer/features/action-required'; -import { UnsynchronizedLyrics } from '/@/renderer/features/lyrics/unsynchronized-lyrics'; -import { useCurrentSong, usePlayerStore } from '/@/renderer/store'; import { - FullLyricsMetadata, - LyricsOverride, - SynchronizedLyricMetadata, - UnsynchronizedLyricMetadata, -} from '/@/renderer/api/types'; + UnsynchronizedLyrics, + UnsynchronizedLyricsProps, +} from '/@/renderer/features/lyrics/unsynchronized-lyrics'; +import { useCurrentSong, usePlayerStore } from '/@/renderer/store'; +import { FullLyricsMetadata, LyricSource, LyricsOverride } from '/@/renderer/api/types'; import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions'; import { queryKeys } from '/@/renderer/api/query-keys'; import { queryClient } from '/@/renderer/lib/react-query'; @@ -84,17 +82,9 @@ const ScrollContainer = styled(motion.div)` } `; -function isSynchronized( - data: Partial | undefined, -): data is SynchronizedLyricMetadata { - // Type magic. The only difference between Synchronized and Unsynchhronized is - // the datatype of lyrics. This makes Typescript happier later... - if (!data) return false; - return Array.isArray(data.lyrics); -} - export const Lyrics = () => { const currentSong = useCurrentSong(); + const [index, setIndex] = useState(0); const { data, isInitialLoading } = useSongLyricsBySong( { @@ -139,7 +129,7 @@ export const Lyrics = () => { }, query: { remoteSongId: override?.id, - remoteSource: override?.source, + remoteSource: override?.source as LyricSource | undefined, song: currentSong, }, serverId: currentSong?.serverId, @@ -150,6 +140,7 @@ export const Lyrics = () => { (state) => state.current.song, () => { setOverride(undefined); + setIndex(0); }, { equalityFn: (a, b) => a?.id === b?.id }, ); @@ -159,16 +150,29 @@ export const Lyrics = () => { }; }, []); + const [lyrics, synced] = useMemo(() => { + if (Array.isArray(data)) { + if (data.length > 0) { + const selectedLyric = data[Math.min(index, data.length)]; + return [selectedLyric, selectedLyric.synced]; + } + } else if (data?.lyrics) { + return [data, Array.isArray(data.lyrics)]; + } + + return [undefined, false]; + }, [data, index]); + + const languages = useMemo(() => { + if (Array.isArray(data)) { + return data.map((lyric, idx) => ({ label: lyric.lang, value: idx.toString() })); + } + return []; + }, [data]); + const isLoadingLyrics = isInitialLoading || isOverrideLoading; - const hasNoLyrics = !data?.lyrics; - - const lyricsMetadata: - | Partial - | Partial - | undefined = data; - - const isSynchronizedLyrics = isSynchronized(lyricsMetadata); + const hasNoLyrics = !lyrics; return ( @@ -198,11 +202,11 @@ export const Lyrics = () => { initial={{ opacity: 0 }} transition={{ duration: 0.5 }} > - {isSynchronizedLyrics ? ( - + {synced ? ( + ) : ( )} @@ -210,6 +214,16 @@ export const Lyrics = () => { )} + {languages.length > 1 && ( +