From 9720fcc202ffd2b88c217404c6209b5619d3d41b Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Thu, 1 Feb 2024 08:17:31 -0800 Subject: [PATCH 1/3] add server info query --- src/renderer/api/controller.ts | 16 +++++++++ src/renderer/api/jellyfin/jellyfin-api.ts | 8 +++++ .../api/jellyfin/jellyfin-controller.ts | 15 ++++++++ src/renderer/api/jellyfin/jellyfin-types.ts | 5 +++ src/renderer/api/subsonic/subsonic-api.ts | 14 ++++++++ .../api/subsonic/subsonic-controller.ts | 30 ++++++++++++++++ src/renderer/api/subsonic/subsonic-types.ts | 17 +++++++++ src/renderer/api/types.ts | 14 ++++++++ src/renderer/app.tsx | 2 ++ .../features/lyrics/queries/lyric-query.ts | 21 +++++------ .../servers/components/edit-server-form.tsx | 4 +++ src/renderer/hooks/use-server-version.ts | 35 +++++++++++++++++++ src/renderer/types.ts | 2 ++ 13 files changed, 173 insertions(+), 10 deletions(-) create mode 100644 src/renderer/hooks/use-server-version.ts diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 196a848c1..73d0039d0 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -48,6 +48,8 @@ import type { SearchResponse, LyricsArgs, LyricsResponse, + ServerInfo, + ServerInfoArgs, } from '/@/renderer/api/types'; import { ServerType } from '/@/renderer/types'; import { DeletePlaylistResponse, RandomSongListArgs } from './types'; @@ -85,6 +87,7 @@ export type ControllerEndpoint = Partial<{ getPlaylistList: (args: PlaylistListArgs) => Promise; getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; getRandomSongList: (args: RandomSongListArgs) => Promise; + getServerInfo: (args: ServerInfoArgs) => Promise; getSongDetail: (args: SongDetailArgs) => Promise; getSongList: (args: SongListArgs) => Promise; getTopSongs: (args: TopSongListArgs) => Promise; @@ -129,6 +132,7 @@ const endpoints: ApiController = { getPlaylistList: jfController.getPlaylistList, getPlaylistSongList: jfController.getPlaylistSongList, getRandomSongList: jfController.getRandomSongList, + getServerInfo: jfController.getServerInfo, getSongDetail: jfController.getSongDetail, getSongList: jfController.getSongList, getTopSongs: jfController.getTopSongList, @@ -165,6 +169,7 @@ const endpoints: ApiController = { getPlaylistList: ndController.getPlaylistList, getPlaylistSongList: ndController.getPlaylistSongList, getRandomSongList: ssController.getRandomSongList, + getServerInfo: ssController.getServerInfo, getSongDetail: ndController.getSongDetail, getSongList: ndController.getSongList, getTopSongs: ssController.getTopSongList, @@ -198,6 +203,7 @@ const endpoints: ApiController = { getMusicFolderList: ssController.getMusicFolderList, getPlaylistDetail: undefined, getPlaylistList: undefined, + getServerInfo: ssController.getServerInfo, getSongDetail: undefined, getSongList: undefined, getTopSongs: ssController.getTopSongList, @@ -481,6 +487,15 @@ const getLyrics = async (args: LyricsArgs) => { )?.(args); }; +const getServerInfo = async (args: ServerInfoArgs) => { + return ( + apiController( + 'getServerInfo', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getServerInfo'] + )?.(args); +}; + export const controller = { addToPlaylist, authenticate, @@ -500,6 +515,7 @@ export const controller = { getPlaylistList, getPlaylistSongList, getRandomSongList, + getServerInfo, getSongDetail, getSongList, getTopSongList, diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index 3303c7429..d9b3040dd 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -150,6 +150,14 @@ export const contract = c.router({ 400: jfType._response.error, }, }, + getServerInfo: { + method: 'GET', + path: 'system/info', + responses: { + 200: jfType._response.serverInfo, + 400: jfType._response.error, + }, + }, getSimilarArtistList: { method: 'GET', path: 'artists/:id/similar', diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index d1c9faff4..5a1369a28 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -49,6 +49,8 @@ import { genreListSortMap, SongDetailArgs, SongDetailResponse, + ServerInfo, + ServerInfoArgs, } from '/@/renderer/api/types'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { jfNormalize } from './jellyfin-normalize'; @@ -946,6 +948,18 @@ const getSongDetail = async (args: SongDetailArgs): Promise return jfNormalize.song(res.body, apiClientProps.server, ''); }; +const getServerInfo = async (args: ServerInfoArgs): Promise => { + const { apiClientProps } = args; + + const res = await jfApiClient(apiClientProps).getServerInfo(); + + if (res.status !== 200) { + throw new Error('Failed to get song detail'); + } + + return { id: apiClientProps.server?.id, version: res.body.Version }; +}; + export const jfController = { addToPlaylist, authenticate, @@ -965,6 +979,7 @@ export const jfController = { getPlaylistList, getPlaylistSongList, getRandomSongList, + getServerInfo, getSongDetail, getSongList, getTopSongList, diff --git a/src/renderer/api/jellyfin/jellyfin-types.ts b/src/renderer/api/jellyfin/jellyfin-types.ts index 9721ae8a4..b789e406b 100644 --- a/src/renderer/api/jellyfin/jellyfin-types.ts +++ b/src/renderer/api/jellyfin/jellyfin-types.ts @@ -654,6 +654,10 @@ const lyrics = z.object({ Lyrics: z.array(lyricText), }); +const serverInfo = z.object({ + Version: z.string(), +}); + export const jfType = { _enum: { albumArtistList: albumArtistListSort, @@ -707,6 +711,7 @@ export const jfType = { removeFromPlaylist, scrobble, search, + serverInfo, song, songList, topSongsList, diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 5a620f19a..d3beac241 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -50,6 +50,13 @@ export const contract = c.router({ 200: ssType._response.randomSongList, }, }, + getServerInfo: { + method: 'GET', + path: 'getOpenSubsonicExtensions.view', + responses: { + 200: ssType._response.serverInfo, + }, + }, getTopSongsList: { method: 'GET', path: 'getTopSongs.view', @@ -58,6 +65,13 @@ export const contract = c.router({ 200: ssType._response.topSongsList, }, }, + ping: { + method: 'GET', + path: 'ping.view', + responses: { + 200: ssType._response.ping, + }, + }, removeFavorite: { method: 'GET', path: 'unstar.view', diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 32c0de170..352b5d9a5 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -21,6 +21,8 @@ import { SearchResponse, RandomSongListResponse, RandomSongListArgs, + ServerInfo, + ServerInfoArgs, } from '/@/renderer/api/types'; import { randomString } from '/@/renderer/utils'; @@ -368,12 +370,40 @@ const getRandomSongList = async (args: RandomSongListArgs): Promise => { + const { apiClientProps } = args; + + const ping = await ssApiClient(apiClientProps).ping(); + + if (ping.status !== 200) { + throw new Error('Failed to ping server'); + } + + if (!ping.body.openSubsonic || !ping.body.serverVersion) { + return { version: ping.body.version }; + } + + const res = await ssApiClient(apiClientProps).getServerInfo(); + + if (res.status !== 200) { + throw new Error('Failed to get server extensions'); + } + + const features: Record = {}; + for (const extension of res.body.openSubsonicExtensions) { + features[extension.name] = extension.versions; + } + + return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion }; +}; + export const ssController = { authenticate, createFavorite, getArtistInfo, getMusicFolderList, getRandomSongList, + getServerInfo, getTopSongList, removeFavorite, scrobble, diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index 3360081b6..5999d9862 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -206,6 +206,21 @@ const randomSongList = z.object({ }), }); +const ping = z.object({ + openSubsonic: z.boolean().optional(), + serverVersion: z.string().optional(), + version: z.string(), +}); + +const extension = z.object({ + name: z.string(), + versions: z.number().array(), +}); + +const serverInfo = z.object({ + openSubsonicExtensions: z.array(extension), +}); + export const ssType = { _parameters: { albumList: albumListParameters, @@ -229,10 +244,12 @@ export const ssType = { baseResponse, createFavorite, musicFolderList, + ping, randomSongList, removeFavorite, scrobble, search3, + serverInfo, setRating, song, topSongsList, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 5165c7fb1..ec8ce685c 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1139,3 +1139,17 @@ export type FontData = { postscriptName: string; style: string; }; + +export type ServerInfoArgs = BaseEndpointArgs; + +export enum SubsonicExtensions { + FORM_POST = 'formPost', + SONG_LYRICS = 'songLyrics', + TRANSCODE_OFFSET = 'transcodeOffset', +} + +export type ServerInfo = { + features?: Record; + id?: string; + version: string; +}; diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index d64b21498..b834c3a59 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -27,6 +27,7 @@ import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types'; import '@ag-grid-community/styles/ag-grid.css'; import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc'; import i18n from '/@/i18n/i18n'; +import { useServerVersion } from '/@/renderer/hooks/use-server-version'; ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]); @@ -49,6 +50,7 @@ export const App = () => { const remoteSettings = useRemoteSettings(); const textStyleRef = useRef(); useDiscordRpc(); + useServerVersion(); useEffect(() => { if (type === FontType.SYSTEM && system) { diff --git a/src/renderer/features/lyrics/queries/lyric-query.ts b/src/renderer/features/lyrics/queries/lyric-query.ts index 599c58573..249fb118e 100644 --- a/src/renderer/features/lyrics/queries/lyric-query.ts +++ b/src/renderer/features/lyrics/queries/lyric-query.ts @@ -6,6 +6,7 @@ import { InternetProviderLyricResponse, FullLyricsMetadata, LyricGetQuery, + SubsonicExtensions, } from '/@/renderer/api/types'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; import { getServerById, useLyricsSettings } from '/@/renderer/store'; @@ -93,16 +94,6 @@ export const useSongLyricsBySong = ( if (!server) throw new Error('Server not found'); if (!song) return null; - if (song.lyrics) { - return { - artist: song.artists?.[0]?.name, - lyrics: formatLyrics(song.lyrics), - name: song.name, - remote: false, - source: server?.name ?? 'music server', - }; - } - if (server.type === ServerType.JELLYFIN) { const jfLyrics = await api.controller .getLyrics({ @@ -120,6 +111,16 @@ export const useSongLyricsBySong = ( source: server?.name ?? 'music server', }; } + } else if (server.features && SubsonicExtensions.SONG_LYRICS in server.features) { + console.log(1234); + } else if (song.lyrics) { + return { + artist: song.artists?.[0]?.name, + lyrics: formatLyrics(song.lyrics), + name: song.name, + remote: false, + source: server?.name ?? 'music server', + }; } if (fetch) { diff --git a/src/renderer/features/servers/components/edit-server-form.tsx b/src/renderer/features/servers/components/edit-server-form.tsx index 776257e19..47fb110cb 100644 --- a/src/renderer/features/servers/components/edit-server-form.tsx +++ b/src/renderer/features/servers/components/edit-server-form.tsx @@ -12,6 +12,8 @@ import { useAuthStoreActions } from '/@/renderer/store'; import { ServerListItem, ServerType } from '/@/renderer/types'; import { api } from '/@/renderer/api'; import i18n from '/@/i18n/i18n'; +import { queryClient } from '/@/renderer/lib/react-query'; +import { queryKeys } from '/@/renderer/api/query-keys'; const localSettings = isElectron() ? window.electron.localSettings : null; @@ -111,6 +113,8 @@ export const EditServerForm = ({ isUpdate, password, server, onCancel }: EditSer localSettings.passwordRemove(server.id); } } + + queryClient.invalidateQueries({ queryKey: queryKeys.server.root(server.id) }); } catch (err: any) { setIsLoading(false); return toast.error({ message: err?.message }); diff --git a/src/renderer/hooks/use-server-version.ts b/src/renderer/hooks/use-server-version.ts new file mode 100644 index 000000000..6ca1327d8 --- /dev/null +++ b/src/renderer/hooks/use-server-version.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import { useAuthStoreActions, useCurrentServer } from '/@/renderer/store'; +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { controller } from '/@/renderer/api/controller'; + +export const useServerVersion = () => { + const { updateServer } = useAuthStoreActions(); + const server = useCurrentServer(); + + const serverInfo = useQuery({ + enabled: !!server, + queryFn: async ({ signal }) => { + return controller.getServerInfo({ + apiClientProps: { + server, + signal, + }, + }); + }, + queryKey: queryKeys.server.root(server?.id), + }); + + useEffect(() => { + if (server && server.id === serverInfo.data?.id) { + const { version, features } = serverInfo.data; + if (version !== server.version) { + updateServer(server.id, { + features, + version, + }); + } + } + }, [server, serverInfo.data, updateServer]); +}; diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 1ede3dea6..5df65673c 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -62,6 +62,7 @@ export enum ServerType { export type ServerListItem = { credential: string; + features?: Record; id: string; name: string; ndCredential?: string; @@ -70,6 +71,7 @@ export type ServerListItem = { url: string; userId: string | null; username: string; + version?: string; }; export enum PlayerStatus { 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 2/3] 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 | 74 +++++++++++-------- .../features/lyrics/queries/lyric-query.ts | 16 +++- .../features/lyrics/synchronized-lyrics.tsx | 2 +- .../features/lyrics/unsynchronized-lyrics.tsx | 2 +- 9 files changed, 181 insertions(+), 45 deletions(-) diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 73d0039d0..6cd4d6235 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 d3beac241..757575171 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 352b5d9a5..875b971ea 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 5999d9862..9005fe8c5 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 ec8ce685c..7241d82ec 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; - remote: boolean; -} & Omit; - -export type UnsynchronizedLyricMetadata = { - lyrics: string; +export type FullLyricsMetadata = { + lyrics: LyricsResponse; 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 31ae97a0e..111b50d16 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 isLoadingLyrics = isInitialLoading || isOverrideLoading; + 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)]; + } - const hasNoLyrics = !data?.lyrics; + return [undefined, false]; + }, [data, index]); - const lyricsMetadata: - | Partial - | Partial - | undefined = data; + 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 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 && ( + setIndex(parseInt(value!, 10))} + /> + + )} + {isDesktop && sources.length ? (