diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 77dfba3a2..b5a25fd0c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -551,6 +551,7 @@ "lyricAlignment": "Lyric alignment", "lyricOffset": "Lyrics offset (ms)", "lyricGap": "Lyric gap", + "lyricLineLeadTime": "Line lead time (ms)", "lyricSize": "Lyric size", "lyricOpacityNonActive": "Non-active lyric opacity", "lyricScaleNonActive": "Non-active lyric scale", diff --git a/src/main/features/core/lyrics/furigana.ts b/src/main/features/core/lyrics/furigana.ts index aa089b204..d97a02873 100644 --- a/src/main/features/core/lyrics/furigana.ts +++ b/src/main/features/core/lyrics/furigana.ts @@ -1,8 +1,19 @@ import Kuroshiro from 'kuroshiro'; import KuromojiAnalyzer from 'kuroshiro-analyzer-kuromoji'; +import { hasJapanese, kanaToRomaji, patchTokens } from 'kuroshiro/lib/util'; // doc: https://kuroshiro.org +export type LyricTextToken = { + endChar: number; + startChar: number; + text: string; +}; + +export type RomajiToken = LyricTextToken & { + romaji: string; +}; + let kuroshiroInstance: any = null; let initPromise: null | Promise = null; @@ -39,6 +50,20 @@ export const convertFurigana = async (text: string): Promise => { } }; +export const convertFuriganaFragment = async (text: string): Promise => { + if (!text || !hasJapanese(text)) { + return text; + } + + try { + const kuroshiro = await getKuroshiro(); + return await kuroshiro.convert(text, { mode: 'furigana', to: 'hiragana' }); + } catch (e) { + console.error('Furigana fragment conversion error: ', e); + return text; + } +}; + export const convertRomaji = async (text: string): Promise => { const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro; @@ -52,3 +77,70 @@ export const convertRomaji = async (text: string): Promise => { return ''; } }; + +export const parseLyricsTextTokens = async (text: string): Promise => { + if (!text || !hasJapanese(text)) { + return []; + } + + try { + const kuroshiro = await getKuroshiro(); + const rawTokens = await kuroshiro._analyzer.parse(text); + const tokens = patchTokens(rawTokens); + + let cursor = 0; + + return tokens.map((token: { surface_form: string }) => { + const surface = token.surface_form; + const startChar = cursor; + cursor += surface.length; + + return { + endChar: cursor, + startChar, + text: surface, + }; + }); + } catch (e) { + console.error('Lyrics token parse error: ', e); + return []; + } +}; + +export const convertRomajiTokens = async (text: string): Promise => { + const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro; + + if (!KuroshiroClass.Util.hasKana(text)) { + return []; + } + + try { + const kuroshiro = await getKuroshiro(); + const rawTokens = await kuroshiro._analyzer.parse(text); + const tokens = patchTokens(rawTokens); + + let cursor = 0; + + return tokens.map( + (token: { pronunciation?: string; reading: string; surface_form: string }) => { + const surface = token.surface_form; + const startChar = cursor; + cursor += surface.length; + + const romaji = hasJapanese(surface) + ? kanaToRomaji(token.pronunciation || token.reading) + : surface; + + return { + endChar: cursor, + romaji, + startChar, + text: surface, + }; + }, + ); + } catch (e) { + console.error('Romaji token conversion error: ', e); + return []; + } +}; diff --git a/src/main/features/core/lyrics/index.ts b/src/main/features/core/lyrics/index.ts index 2e0d7e2ba..d6061db5a 100644 --- a/src/main/features/core/lyrics/index.ts +++ b/src/main/features/core/lyrics/index.ts @@ -1,7 +1,13 @@ import { ipcMain } from 'electron'; import { store } from '../settings'; -import { convertFurigana, convertRomaji } from './furigana'; +import { + convertFurigana, + convertFuriganaFragment, + convertRomaji, + convertRomajiTokens, + parseLyricsTextTokens, +} from './furigana'; import { getLyricsBySongId as getGenius, getSearchResults as searchGenius } from './genius'; import { getLyricsBySongId as getLrcLib, getSearchResults as searchLrcLib } from './lrclib'; import { getLyricsBySongId as getNetease, getSearchResults as searchNetease } from './netease'; @@ -237,6 +243,18 @@ ipcMain.handle('lyric-convert-furigana', async (_event, text: string) => { return await convertFurigana(text); }); +ipcMain.handle('lyric-convert-furigana-fragment', async (_event, text: string) => { + return await convertFuriganaFragment(text); +}); + +ipcMain.handle('lyric-parse-text-tokens', async (_event, text: string) => { + return await parseLyricsTextTokens(text); +}); + ipcMain.handle('lyric-convert-romaji', async (_event, text: string) => { return await convertRomaji(text); }); + +ipcMain.handle('lyric-convert-romaji-tokens', async (_event, text: string) => { + return await convertRomajiTokens(text); +}); diff --git a/src/preload/lyrics.ts b/src/preload/lyrics.ts index ad60f5ea3..21c96cb0b 100644 --- a/src/preload/lyrics.ts +++ b/src/preload/lyrics.ts @@ -30,15 +30,30 @@ const convertFurigana = (text: string): Promise => { return ipcRenderer.invoke('lyric-convert-furigana', text); }; +const convertFuriganaFragment = (text: string): Promise => { + return ipcRenderer.invoke('lyric-convert-furigana-fragment', text); +}; + +const parseLyricsTextTokens = (text: string) => { + return ipcRenderer.invoke('lyric-parse-text-tokens', text); +}; + const convertRomaji = (text: string): Promise => { return ipcRenderer.invoke('lyric-convert-romaji', text); }; +const convertRomajiTokens = (text: string) => { + return ipcRenderer.invoke('lyric-convert-romaji-tokens', text); +}; + export const lyrics = { convertFurigana, + convertFuriganaFragment, convertRomaji, + convertRomajiTokens, getRemoteLyricsByRemoteId, getRemoteLyricsBySong, + parseLyricsTextTokens, searchRemoteLyrics, }; diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 7df7d48e0..976b4ee82 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -956,7 +956,10 @@ export const JellyfinController: InternalControllerEndpoint = { return res.body.Lyrics.map((lyric) => lyric.Text).join('\n'); } - return res.body.Lyrics.map((lyric) => [lyric.Start! / 1e4, lyric.Text]); + return res.body.Lyrics.map((lyric) => ({ + startMs: lyric.Start! / 1e4, + text: lyric.Text, + })); }, getMusicFolderList: async (args) => { const { apiClientProps } = args; diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 0af0f095e..21168a517 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -8,6 +8,7 @@ import md5 from 'md5'; import { z } from 'zod'; import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; +import { mapStructuredLyric } from '/@/renderer/api/subsonic/subsonic-structured-lyrics'; import { getDefaultTranscodingProfiles, getDirectPlayProfiles, @@ -21,7 +22,13 @@ import { ssType, SubsonicExtensions, } from '/@/shared/api/subsonic/subsonic-types'; -import { hasFeature, sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/shared/api/utils'; +import { + hasFeature, + hasFeatureWithVersion, + sortAlbumArtistList, + sortAlbumList, + sortSongList, +} from '/@/shared/api/utils'; import { AlbumListSort, GenreListSort, @@ -1381,7 +1388,7 @@ export const SubsonicController: InternalControllerEndpoint = { } if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) { - features.lyricsMultipleStructured = [1]; + features.lyricsMultipleStructured = subsonicFeatures[SubsonicExtensions.SONG_LYRICS]; } if (subsonicFeatures[SubsonicExtensions.FORM_POST]) { @@ -1945,9 +1952,16 @@ export const SubsonicController: InternalControllerEndpoint = { }, getStructuredLyrics: async (args) => { const { apiClientProps, query } = args; + const server = apiClientProps.server; + const supportsEnhancedLyrics = hasFeatureWithVersion( + server, + ServerFeature.LYRICS_MULTIPLE_STRUCTURED, + 2, + ); const res = await ssApiClient(apiClientProps).getStructuredLyrics({ query: { + enhanced: supportsEnhancedLyrics ? true : undefined, id: query.songId, }, }); @@ -1962,28 +1976,9 @@ export const SubsonicController: InternalControllerEndpoint = { return []; } - return lyrics.map((lyric) => { - const baseLyric = { - artist: lyric.displayArtist || '', - lang: lyric.lang, - name: lyric.displayTitle || '', - remote: false, - source: apiClientProps.server?.name || 'music server', - }; + const 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, - }; - }); + return lyrics.map((lyric) => mapStructuredLyric(lyric, source)); }, getTopSongs: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/api/subsonic/subsonic-structured-lyrics.ts b/src/renderer/api/subsonic/subsonic-structured-lyrics.ts new file mode 100644 index 000000000..b995a05e4 --- /dev/null +++ b/src/renderer/api/subsonic/subsonic-structured-lyrics.ts @@ -0,0 +1,118 @@ +import { z } from 'zod'; + +import { sliceUtf8Bytes } from '/@/renderer/utils/utf8-byte-slice'; +import { ssType } from '/@/shared/api/subsonic/subsonic-types'; +import { + LyricAgent, + LyricsKind, + StructuredLyric, + SyncedCueLine, + SyncedWordCue, + SynchronizedLyricLine, +} from '/@/shared/types/domain-types'; + +type ApiStructuredLyric = NonNullable< + z.infer['lyricsList'] +>['structuredLyrics'] extends Array | undefined + ? T + : never; + +const mapCueWords = ( + apiCueLine: NonNullable[number], +): SyncedWordCue[] => { + if (!apiCueLine.cue?.length) { + return []; + } + + return apiCueLine.cue.map((cue) => ({ + endMs: cue.end, + startMs: cue.start, + text: cue.value || sliceUtf8Bytes(apiCueLine.value, cue.byteStart, cue.byteEnd), + })); +}; + +const mapCueLine = ( + apiCueLine: NonNullable[number], +): SyncedCueLine => ({ + agentId: apiCueLine.agentId, + endMs: apiCueLine.end, + index: apiCueLine.index, + startMs: apiCueLine.start, + value: apiCueLine.value, + words: mapCueWords(apiCueLine), +}); + +const attachCueLines = ( + lines: SynchronizedLyricLine[], + apiCueLines: NonNullable, +): SynchronizedLyricLine[] => { + const cueLinesByIndex = new Map(); + + for (const apiCueLine of apiCueLines) { + const mappedCueLine = mapCueLine(apiCueLine); + const existing = cueLinesByIndex.get(apiCueLine.index) ?? []; + existing.push(mappedCueLine); + cueLinesByIndex.set(apiCueLine.index, existing); + } + + return lines.map((line, index) => { + const cueLines = cueLinesByIndex.get(index); + + if (!cueLines?.length) { + return line; + } + + return { + ...line, + cueLines, + }; + }); +}; + +const mapAgents = (agents: ApiStructuredLyric['agents']): LyricAgent[] | undefined => { + if (!agents?.length) { + return undefined; + } + + return agents.map((agent) => ({ + id: agent.id, + name: agent.name, + role: agent.role, + })); +}; + +export const mapStructuredLyric = (lyric: ApiStructuredLyric, source: string): StructuredLyric => { + const baseLyric = { + artist: lyric.displayArtist || '', + lang: lyric.lang, + name: lyric.displayTitle || '', + offsetMs: lyric.offset ?? 0, + remote: false, + source, + }; + + if (lyric.synced) { + let lines: SynchronizedLyricLine[] = lyric.line.map((line) => ({ + startMs: line.start ?? 0, + text: line.value, + })); + + if (lyric.cueLine?.length) { + lines = attachCueLines(lines, lyric.cueLine); + } + + return { + ...baseLyric, + agents: mapAgents(lyric.agents), + kind: (lyric.kind ?? 'main') as LyricsKind, + lyrics: lines, + synced: true, + }; + } + + return { + ...baseLyric, + lyrics: lyric.line.map((line) => line.value).join('\n'), + synced: false, + }; +}; diff --git a/src/renderer/features/lyrics/api/lyric-conversion.ts b/src/renderer/features/lyrics/api/lyric-conversion.ts new file mode 100644 index 000000000..6a5f76981 --- /dev/null +++ b/src/renderer/features/lyrics/api/lyric-conversion.ts @@ -0,0 +1,315 @@ +import { SyncedWordCue } from '/@/shared/types/domain-types'; + +export type LyricTextToken = { + endChar: number; + startChar: number; + text: string; +}; + +export type RomajiToken = LyricTextToken & { + romaji: string; +}; + +const rangesOverlap = (aStart: number, aEnd: number, bStart: number, bEnd: number): boolean => + aStart < bEnd && bStart < aEnd; + +const sliceRomajiForOverlap = ( + token: RomajiToken, + overlapStart: number, + overlapEnd: number, +): string => { + const tokenLen = token.endChar - token.startChar; + if (tokenLen <= 0 || overlapEnd <= overlapStart || !token.romaji.length) { + return ''; + } + + const relStart = Math.max(0, overlapStart - token.startChar); + const relEnd = Math.min(tokenLen, overlapEnd - token.startChar); + if (relEnd <= relStart) { + return ''; + } + + const startIdx = Math.floor((relStart / tokenLen) * token.romaji.length); + const endIdx = + relEnd >= tokenLen + ? token.romaji.length + : Math.floor((relEnd / tokenLen) * token.romaji.length); + + if (endIdx <= startIdx) { + return token.romaji.slice(startIdx, Math.min(startIdx + 1, token.romaji.length)); + } + + return token.romaji.slice(startIdx, endIdx); +}; + +const findWordCueEndingToken = ( + token: RomajiToken, + wordRanges: { end: number; start: number }[], +): number => { + let bestIndex = -1; + let bestEnd = -1; + + for (let index = 0; index < wordRanges.length; index += 1) { + const range = wordRanges[index]; + const overlaps = + range.start < token.endChar && + range.end > token.startChar && + range.end <= token.endChar; + + if (overlaps && range.end > bestEnd) { + bestEnd = range.end; + bestIndex = index; + } + } + + return bestIndex; +}; + +const isWhitespaceToken = (token: RomajiToken): boolean => /^\s+$/u.test(token.text); + +type FuriganaToken = LyricTextToken & { + furigana: string; +}; + +const KANJI_RE = /[\u4e00-\u9fff]/u; +const RUBY_BLOCK_RE = /^([^<]*)[^<]*<\/rp>([^<]*)<\/rt>[^<]*<\/rp><\/ruby>$/; + +const sliceReadingByProportion = ( + reading: string, + relStart: number, + relEnd: number, + tokenLen: number, +): string => { + if (tokenLen <= 0 || relEnd <= relStart || !reading.length) { + return ''; + } + + const startIdx = Math.floor((relStart / tokenLen) * reading.length); + const endIdx = + relEnd >= tokenLen ? reading.length : Math.floor((relEnd / tokenLen) * reading.length); + + if (endIdx <= startIdx) { + return reading.slice(startIdx, Math.min(startIdx + 1, reading.length)); + } + + return reading.slice(startIdx, endIdx); +}; + +const wrapRuby = (base: string, reading: string): string => + `${base}(${reading})`; + +const buildPerCharFuriganaSegments = (tokenText: string, furiganaHtml: string): string[] => { + const segments: string[] = []; + let htmlCursor = 0; + + while (htmlCursor < furiganaHtml.length && segments.length < tokenText.length) { + const rubyStart = furiganaHtml.indexOf('', htmlCursor); + + if (rubyStart === -1 || rubyStart > htmlCursor) { + const plainEnd = rubyStart === -1 ? furiganaHtml.length : rubyStart; + const plain = furiganaHtml.slice(htmlCursor, plainEnd); + for (const char of plain) { + segments.push(char); + } + htmlCursor = plainEnd; + continue; + } + + const rubyEnd = furiganaHtml.indexOf('', rubyStart); + if (rubyEnd === -1) { + break; + } + + const rubyBlock = furiganaHtml.slice(rubyStart, rubyEnd + 7); + const baseMatch = rubyBlock.match(RUBY_BLOCK_RE); + if (!baseMatch) { + htmlCursor = rubyEnd + 7; + continue; + } + + const [, base, reading] = baseMatch; + if (base.length === 1) { + segments.push(rubyBlock); + } else { + for (let index = 0; index < base.length; index += 1) { + const char = base[index]; + const charReading = sliceReadingByProportion( + reading, + index, + index + 1, + base.length, + ); + segments.push(KANJI_RE.test(char) ? wrapRuby(char, charReading) : char); + } + } + + htmlCursor = rubyEnd + 7; + } + + if (segments.length === tokenText.length) { + return segments; + } + + return [...tokenText].map((char, index) => { + if (!KANJI_RE.test(char)) { + return char; + } + + const singleRuby = furiganaHtml.match(RUBY_BLOCK_RE); + if (singleRuby && singleRuby[1] === tokenText) { + const charReading = sliceReadingByProportion( + singleRuby[2], + index, + index + 1, + tokenText.length, + ); + return wrapRuby(char, charReading); + } + + return char; + }); +}; + +const sliceFuriganaForOverlap = ( + token: FuriganaToken, + overlapStart: number, + overlapEnd: number, +): string => { + const tokenLen = token.endChar - token.startChar; + if (tokenLen <= 0 || overlapEnd <= overlapStart) { + return ''; + } + + const relStart = Math.max(0, overlapStart - token.startChar); + const relEnd = Math.min(tokenLen, overlapEnd - token.startChar); + if (relEnd <= relStart) { + return ''; + } + + if (relStart === 0 && relEnd === tokenLen) { + return token.furigana; + } + + const charSegments = buildPerCharFuriganaSegments(token.text, token.furigana); + return charSegments.slice(relStart, relEnd).join(''); +}; + +export const alignRomajiTokensToWordCues = ( + cueValue: string, + words: SyncedWordCue[], + tokens: RomajiToken[], +): null | SyncedWordCue[] => { + const joined = words.map((word) => word.text).join(''); + if (joined.length !== cueValue.length) { + return null; + } + + let charOffset = 0; + const wordRanges: { end: number; start: number }[] = []; + const aligned: SyncedWordCue[] = []; + + for (const word of words) { + const wordStart = charOffset; + const wordEnd = charOffset + word.text.length; + charOffset = wordEnd; + wordRanges.push({ end: wordEnd, start: wordStart }); + + const overlapping = tokens.filter((token) => + rangesOverlap(wordStart, wordEnd, token.startChar, token.endChar), + ); + + const romajiParts = overlapping + .map((token) => { + const overlapStart = Math.max(wordStart, token.startChar); + const overlapEnd = Math.min(wordEnd, token.endChar); + return sliceRomajiForOverlap(token, overlapStart, overlapEnd); + }) + .filter((part) => part.length > 0); + + const romajiText = romajiParts.join(' '); + + aligned.push({ + ...word, + text: romajiText || word.text, + }); + } + + for (let tokenIndex = 0; tokenIndex < tokens.length - 1; tokenIndex += 1) { + const token = tokens[tokenIndex]; + const nextToken = tokens[tokenIndex + 1]; + + if (isWhitespaceToken(nextToken)) { + continue; + } + + const wordIndex = findWordCueEndingToken(token, wordRanges); + if (wordIndex < 0) { + continue; + } + + const currentText = aligned[wordIndex].text; + if (!currentText || currentText.endsWith(' ')) { + continue; + } + + aligned[wordIndex] = { + ...aligned[wordIndex], + text: `${currentText} `, + }; + } + + return aligned; +}; + +export const alignFuriganaToWordCues = async ( + cueValue: string, + words: SyncedWordCue[], + tokens: LyricTextToken[], + convertFuriganaFragment: (text: string) => Promise, +): Promise => { + const joined = words.map((word) => word.text).join(''); + if (joined.length !== cueValue.length) { + return null; + } + + const furiganaTokens: FuriganaToken[] = await Promise.all( + tokens.map(async (token) => ({ + ...token, + furigana: await convertFuriganaFragment(token.text), + })), + ); + + let charOffset = 0; + const aligned: SyncedWordCue[] = []; + + for (const word of words) { + const wordStart = charOffset; + const wordEnd = charOffset + word.text.length; + charOffset = wordEnd; + + const parts: string[] = []; + + for (const token of furiganaTokens) { + if (!rangesOverlap(wordStart, wordEnd, token.startChar, token.endChar)) { + continue; + } + + const overlapStart = Math.max(wordStart, token.startChar); + const overlapEnd = Math.min(wordEnd, token.endChar); + const furiganaPart = sliceFuriganaForOverlap(token, overlapStart, overlapEnd); + + if (!furiganaPart) { + continue; + } + + parts.push(furiganaPart); + } + + aligned.push({ + ...word, + text: parts.join('') || word.text, + }); + } + + return aligned; +}; diff --git a/src/renderer/features/lyrics/api/lyrics-api.ts b/src/renderer/features/lyrics/api/lyrics-api.ts index 0e07f6202..23886013e 100644 --- a/src/renderer/features/lyrics/api/lyrics-api.ts +++ b/src/renderer/features/lyrics/api/lyrics-api.ts @@ -3,6 +3,7 @@ import isElectron from 'is-electron'; import { api } from '/@/renderer/api'; import { queryKeys } from '/@/renderer/api/query-keys'; +import { getDefaultStructuredIndex } from '/@/renderer/features/lyrics/api/lyrics-utils'; import { queryClient, QueryHookArgs } from '/@/renderer/lib/react-query'; import { getServerById, useSettingsStore } from '/@/renderer/store'; import { hasFeature } from '/@/shared/api/utils'; @@ -17,7 +18,7 @@ import { QueueSong, Song, StructuredLyric, - SynchronizedLyricsArray, + SynchronizedLyrics, } from '/@/shared/types/domain-types'; import { LyricSource } from '/@/shared/types/domain-types'; import { LyricsResponse } from '/@/shared/types/domain-types'; @@ -47,7 +48,7 @@ const alternateTimeExp = /\[(\d*),(\d*)]([^\n]+)(\n|$)/g; const formatLyrics = (lyrics: string) => { const synchronizedLines = lyrics.matchAll(timeExp); - const formattedLyrics: SynchronizedLyricsArray = []; + const formattedLyrics: SynchronizedLyrics = []; for (const line of synchronizedLines) { const [, minute, sec, ms, text] = line; @@ -57,7 +58,7 @@ const formatLyrics = (lyrics: string) => { const timeInMilis = (minutes * 60 + seconds) * 1000 + milis; - formattedLyrics.push([timeInMilis, text]); + formattedLyrics.push({ startMs: timeInMilis, text }); } if (formattedLyrics.length > 0) return formattedLyrics; @@ -69,7 +70,7 @@ const formatLyrics = (lyrics: string) => { .replaceAll(/\(\d+,\d+\)/g, '') .replaceAll(/\s,/g, ',') .replaceAll(/\s\./g, '.'); - formattedLyrics.push([Number(timeInMilis), cleanText]); + formattedLyrics.push({ startMs: Number(timeInMilis), text: cleanText }); } if (formattedLyrics.length > 0) return formattedLyrics; @@ -149,6 +150,7 @@ export async function fetchLocalLyrics(params: { song: QueueSong; }): Promise { const { serverId, signal, song } = params; + const server = getServerById(serverId); if (!server) throw new Error('Server not found'); @@ -276,7 +278,6 @@ export const lyricsQueries = { const prev = queryClient.getQueryData(lyricsKey); const overrideSelection = prev?.overrideSelection ?? null; const suppressRemoteAuto = prev?.suppressRemoteAuto ?? false; - const selectedStructuredIndex = prev?.selectedStructuredIndex ?? 0; const selectedOffsetMs = prev?.selectedOffsetMs ?? 0; const preferLocalLyrics = useSettingsStore.getState().lyrics.preferLocalLyrics; @@ -328,6 +329,12 @@ export const lyricsQueries = { ]); } + const selectedStructuredIndex = + prev?.selectedStructuredIndex ?? + (Array.isArray(local) && local.length > 0 + ? getDefaultStructuredIndex(local) + : 0); + const partial: Pick< LyricsQueryResult, | 'local' diff --git a/src/renderer/features/lyrics/api/lyrics-rtl.ts b/src/renderer/features/lyrics/api/lyrics-rtl.ts new file mode 100644 index 000000000..786abfdb9 --- /dev/null +++ b/src/renderer/features/lyrics/api/lyrics-rtl.ts @@ -0,0 +1,3 @@ +const RTL_REGEX = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}]/u; + +export const testRtl = (text: string): boolean => RTL_REGEX.test(text); diff --git a/src/renderer/features/lyrics/api/lyrics-scroll.ts b/src/renderer/features/lyrics/api/lyrics-scroll.ts new file mode 100644 index 000000000..ad45f1b90 --- /dev/null +++ b/src/renderer/features/lyrics/api/lyrics-scroll.ts @@ -0,0 +1,88 @@ +const MIN_SCROLL_DURATION_MS = 350; +const MAX_SCROLL_DURATION_MS = 850; +const SCROLL_DURATION_PER_PX_MS = 0.55; +const INSTANT_SCROLL_THRESHOLD_PX = 2; + +const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3; + +export const computeScrollDurationMs = (deltaPx: number): number => + Math.min( + Math.max(Math.abs(deltaPx) * SCROLL_DURATION_PER_PX_MS, MIN_SCROLL_DURATION_MS), + MAX_SCROLL_DURATION_MS, + ); + +const activeAnimations = new WeakMap(); + +export const cancelScrollAnimation = (element: HTMLElement): void => { + const frameId = activeAnimations.get(element); + if (frameId !== undefined) { + cancelAnimationFrame(frameId); + activeAnimations.delete(element); + } +}; + +export interface AnimateScrollTopOptions { + onComplete?: () => void; + smooth?: boolean; +} + +export const animateScrollTop = ( + element: HTMLElement, + targetTop: number, + options: AnimateScrollTopOptions = {}, +): (() => void) => { + const { onComplete, smooth = true } = options; + const startTop = element.scrollTop; + const delta = targetTop - startTop; + + cancelScrollAnimation(element); + + if (!smooth || Math.abs(delta) <= INSTANT_SCROLL_THRESHOLD_PX) { + element.scrollTop = targetTop; + onComplete?.(); + return () => {}; + } + + const durationMs = computeScrollDurationMs(delta); + const startTime = performance.now(); + let frameId: null | number = null; + let cancelled = false; + + const cancel = (): void => { + if (cancelled) { + return; + } + + cancelled = true; + if (frameId !== null) { + cancelAnimationFrame(frameId); + activeAnimations.delete(element); + frameId = null; + } + }; + + const step = (now: number): void => { + if (cancelled) { + return; + } + + const elapsed = now - startTime; + const progress = Math.min(elapsed / durationMs, 1); + const eased = easeOutCubic(progress); + element.scrollTop = startTop + delta * eased; + + if (progress < 1) { + frameId = requestAnimationFrame(step); + activeAnimations.set(element, frameId); + } else { + activeAnimations.delete(element); + frameId = null; + onComplete?.(); + } + }; + + frameId = requestAnimationFrame(step); + activeAnimations.set(element, frameId); + + return cancel; +}; diff --git a/src/renderer/features/lyrics/api/lyrics-utils.ts b/src/renderer/features/lyrics/api/lyrics-utils.ts new file mode 100644 index 000000000..6ab2e61ee --- /dev/null +++ b/src/renderer/features/lyrics/api/lyrics-utils.ts @@ -0,0 +1,179 @@ +import { + LyricsKind, + StructuredLyric, + SyncedCueLine, + SyncedWordCue, + SynchronizedLyricLine, + SynchronizedLyrics, + SynchronizedLyricsLineTuple, +} from '/@/shared/types/domain-types'; + +export const isLyricsLineTuple = ( + line: SynchronizedLyricLine | SynchronizedLyricsLineTuple, +): line is SynchronizedLyricsLineTuple => Array.isArray(line); + +export const normalizeLyricsLine = ( + line: SynchronizedLyricLine | SynchronizedLyricsLineTuple, +): SynchronizedLyricLine => { + if (isLyricsLineTuple(line)) { + return { startMs: line[0], text: line[1] ?? '' }; + } + + return { + ...line, + text: line.text ?? '', + }; +}; + +export const normalizeLyrics = ( + lyrics: SynchronizedLyrics | SynchronizedLyricsLineTuple[], +): SynchronizedLyrics => lyrics.map(normalizeLyricsLine); + +export const getLyricLineStartMs = ( + line: SynchronizedLyricLine | SynchronizedLyricsLineTuple, +): number => normalizeLyricsLine(line).startMs; + +export const getLyricLineText = ( + line: SynchronizedLyricLine | SynchronizedLyricsLineTuple, +): string => normalizeLyricsLine(line).text ?? ''; + +export const getCurrentLyricIndex = (lyrics: SynchronizedLyrics, timeInMs: number): number => { + if (!lyrics.length) { + return -1; + } + + let index = -1; + for (let idx = 0; idx < lyrics.length; idx += 1) { + if (timeInMs < getLyricLineStartMs(lyrics[idx])) { + break; + } + index = idx; + } + + return index; +}; + +export const getCurrentWordIndex = (cueLine: SyncedCueLine, timeInMs: number): number => { + if (!cueLine.words.length) { + return -1; + } + + for (let wordIndex = 0; wordIndex < cueLine.words.length; wordIndex += 1) { + const word = cueLine.words[wordIndex]; + if (timeInMs >= word.startMs && timeInMs < word.endMs) { + return wordIndex; + } + } + + return -1; +}; + +export const getWordProgress = (word: SyncedWordCue, timeInMs: number): number => { + const duration = word.endMs - word.startMs; + + if (duration <= 0) { + return timeInMs >= word.endMs ? 100 : 0; + } + + const elapsed = Math.max(0, Math.min(duration, timeInMs - word.startMs)); + return (elapsed / duration) * 100; +}; + +export const getLineEndMs = (line: SynchronizedLyricLine): number => { + if (!line.cueLines?.length) { + return line.startMs; + } + + let endMs = line.startMs; + + for (const cueLine of line.cueLines) { + endMs = Math.max(endMs, cueLine.endMs); + + for (const word of cueLine.words) { + endMs = Math.max(endMs, word.endMs); + } + } + + return endMs; +}; + +export const lyricsHasWordCues = (lyrics: SynchronizedLyrics): boolean => + lyrics.some((line) => line.cueLines?.some((cueLine) => cueLine.words.length > 0)); + +export const findOverlayLineByTime = ( + overlayLyrics: null | SynchronizedLyrics | undefined, + startMs: number, +): string | undefined => { + if (!overlayLyrics?.length) { + return undefined; + } + + let match: SynchronizedLyricLine | undefined; + + for (const rawLine of overlayLyrics) { + const line = normalizeLyricsLine(rawLine); + if (line.startMs <= startMs) { + match = line; + continue; + } + + break; + } + + return match?.text; +}; + +export type LyricsLayers = { + main: StructuredLyric[]; + others: StructuredLyric[]; + pronunciation: null | StructuredLyric; + translation: null | StructuredLyric; +}; + +const getStructuredKind = (lyric: StructuredLyric): LyricsKind => { + if (!lyric.synced) { + return 'main'; + } + + return lyric.kind ?? 'main'; +}; + +export const getLyricsLayers = (local: StructuredLyric[]): LyricsLayers => { + const main: StructuredLyric[] = []; + const others: StructuredLyric[] = []; + let pronunciation: null | StructuredLyric = null; + let translation: null | StructuredLyric = null; + + for (const lyric of local) { + const kind = getStructuredKind(lyric); + + if (kind === 'main') { + main.push(lyric); + } else { + others.push(lyric); + } + + if (kind === 'translation' && !translation) { + translation = lyric; + } + + if (kind === 'pronunciation' && !pronunciation) { + pronunciation = lyric; + } + } + + return { main, others, pronunciation, translation }; +}; + +export const getDefaultStructuredIndex = (local: StructuredLyric[]): number => { + const mainIndex = local.findIndex( + (lyric) => lyric.synced && getStructuredKind(lyric) === 'main', + ); + + return mainIndex >= 0 ? mainIndex : 0; +}; + +export const formatStructuredLyricLabel = (lyric: StructuredLyric): string => { + const kind = getStructuredKind(lyric); + return kind === 'main' ? lyric.lang : `${lyric.lang} (${kind})`; +}; diff --git a/src/renderer/features/lyrics/api/split-word-cue.ts b/src/renderer/features/lyrics/api/split-word-cue.ts new file mode 100644 index 000000000..92ffa8b37 --- /dev/null +++ b/src/renderer/features/lyrics/api/split-word-cue.ts @@ -0,0 +1,64 @@ +import { SyncedWordCue } from '/@/shared/types/domain-types'; + +// eslint-disable-next-line no-irregular-whitespace +const BREAK_CHAR_RE = /[\s​­\p{Dash_Punctuation}]/u; +const TRAILING_WS_RE = /\s+$/; +const SEGMENT_LENGTH_THRESHOLD = 5; +const GRAPHEME_WRAP_RE = + /[\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana}\p{sc=Hangul}\p{sc=Thai}\p{sc=Lao}\p{sc=Khmer}\p{sc=Myanmar}]/u; + +export type SplitWordCue = SyncedWordCue & { + isWrapAfter?: boolean; +}; + +const shouldSplit = (text: string): boolean => { + const core = text.replace(TRAILING_WS_RE, ''); + if (core.length <= SEGMENT_LENGTH_THRESHOLD) { + return false; + } + + return !BREAK_CHAR_RE.test(core); +}; + +const segment = (text: string): string[] => { + try { + const wordSeg = new Intl.Segmenter(undefined, { granularity: 'word' }); + const words = Array.from(wordSeg.segment(text), (entry) => entry.segment); + if (words.length > 1) { + return words; + } + + if (!GRAPHEME_WRAP_RE.test(text)) { + return [text]; + } + + const graphSeg = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + return Array.from(graphSeg.segment(text), (entry) => entry.segment); + } catch { + return Array.from(text); + } +}; + +export const splitWordCue = (word: SyncedWordCue): SplitWordCue[] => { + if (!shouldSplit(word.text)) { + return [{ ...word, isWrapAfter: false }]; + } + + const segments = segment(word.text); + if (segments.length <= 1) { + return [{ ...word, isWrapAfter: false }]; + } + + const duration = word.endMs - word.startMs; + const perDuration = duration / segments.length; + + return segments.map((seg, index) => ({ + endMs: word.startMs + (index + 1) * perDuration, + isWrapAfter: index < segments.length - 1, + startMs: word.startMs + index * perDuration, + text: seg, + })); +}; + +export const splitWordCues = (words: SyncedWordCue[]): SplitWordCue[] => + words.flatMap((word) => splitWordCue(word)); diff --git a/src/renderer/features/lyrics/components/lyrics-export-form.tsx b/src/renderer/features/lyrics/components/lyrics-export-form.tsx index 97224a206..1ba705360 100644 --- a/src/renderer/features/lyrics/components/lyrics-export-form.tsx +++ b/src/renderer/features/lyrics/components/lyrics-export-form.tsx @@ -35,7 +35,7 @@ export const LyricsExportForm = ({ lyrics, offsetMs, synced }: LyricsExportFormP const contents = lyrics.lyrics .map( (lyric) => - `[${formatDuration(lyric[0], { leading: true, ms: true })}]${lyric[1]}`, + `[${formatDuration(lyric.startMs, { leading: true, ms: true })}]${lyric.text}`, ) .join('\n'); @@ -46,7 +46,7 @@ ${contents} `; } else { if (Array.isArray(lyrics.lyrics)) { - return lyrics.lyrics.map((lyric) => lyric[1]).join('\n') + '\n'; + return lyrics.lyrics.map((lyric) => lyric.text).join('\n') + '\n'; } return lyrics.lyrics; } diff --git a/src/renderer/features/lyrics/components/lyrics-search-form.tsx b/src/renderer/features/lyrics/components/lyrics-search-form.tsx index 036ed7e57..230e3bffa 100644 --- a/src/renderer/features/lyrics/components/lyrics-search-form.tsx +++ b/src/renderer/features/lyrics/components/lyrics-search-form.tsx @@ -9,7 +9,9 @@ import styles from './lyrics-search-form.module.css'; import i18n from '/@/i18n/i18n'; import { lyricsQueries } from '/@/renderer/features/lyrics/api/lyrics-api'; +import { lyricsHasWordCues } from '/@/renderer/features/lyrics/api/lyrics-utils'; import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form'; +import { SynchronizedKaraokeLyrics } from '/@/renderer/features/lyrics/synchronized-karaoke-lyrics'; import { SynchronizedLyrics, SynchronizedLyricsProps, @@ -258,16 +260,29 @@ export const LyricsSearchForm = ({ artist, name, onSearchOverride }: LyricSearch style={{ width: '100%' }} > {Array.isArray(previewData) ? ( - + lyricsHasWordCues(previewData) ? ( + + ) : ( + + ) ) : ( description: '', title: t('page.fullscreenPlayer.config.followCurrentLyric'), }, + { + control: ( + { + const value = Number(e.currentTarget.value); + updateLyricsSetting({ lineLeadTimeMs: value }); + }} + step={50} + width={100} + /> + ), + description: '', + title: t('page.fullscreenPlayer.config.lyricLineLeadTime'), + }, { control: ( void) | null; + doneFirstInstantScroll: boolean; + nextScrollAllowedTime: number; + pendingScroll: boolean; + programmaticScrollUntil: number; + queuedScroll: boolean; + scrollPos: number; + scrollResumeTime: number; + skipScrolls: number; + skipScrollsDecayTimes: number[]; + wasUserScrolling: boolean; +} + +export interface TickOptions { + currentTimeMs: number; + eventCreationTime: number; + follow?: boolean; + forceResync?: boolean; + isPlaying: boolean; + lineLeadTimeMs?: number; + lyricsData: LyricsData; + onLineActive?: (lineIndex: number) => void; + scrollContainer: HTMLElement; + smoothScroll?: boolean; +} + +const reflow = (element: HTMLElement): void => { + void element.offsetHeight; +}; + +export const createScrollState = (): ScrollState => ({ + cancelScroll: null, + doneFirstInstantScroll: false, + nextScrollAllowedTime: 0, + pendingScroll: false, + programmaticScrollUntil: 0, + queuedScroll: false, + scrollPos: -1, + scrollResumeTime: 0, + skipScrolls: 0, + skipScrollsDecayTimes: [], + wasUserScrolling: false, +}); + +export const createAnimEngineState = (): AnimEngineState => ({ + lastActiveElements: [], + lastEventCreationTime: 0, + lastPlayState: false, + lastTime: 0, + scroll: createScrollState(), + selectedElementIndex: 0, +}); + +export const resetAnimEngine = (state: AnimEngineState): void => { + cancelActiveLyricsScroll(state.scroll); + state.lastActiveElements = []; + state.scroll.skipScrollsDecayTimes = []; + state.scroll.doneFirstInstantScroll = false; + state.scroll.pendingScroll = false; + state.scroll.queuedScroll = false; +}; + +const collectLineParts = (lineElement: HTMLElement): PartData[] => { + const words = lineElement.querySelectorAll( + '.karaoke-word[data-duration]:not(.karaoke-romaji-word)', + ); + + return Array.from(words).map((element) => ({ + animationStartTimeMs: Number.POSITIVE_INFINITY, + duration: Number.parseFloat(element.dataset.duration ?? '0'), + element, + isAnimating: false, + time: Number.parseFloat(element.dataset.time ?? '0'), + })); +}; + +const getRomajiCounterparts = (mainElement: HTMLElement): HTMLElement[] => { + const line = mainElement.closest('.karaoke-line'); + if (!line) { + return []; + } + + const mainStart = Number.parseFloat(mainElement.dataset.time ?? 'NaN'); + const mainDuration = Number.parseFloat(mainElement.dataset.duration ?? '0'); + if (!Number.isFinite(mainStart)) { + return []; + } + + const mainEnd = mainStart + mainDuration; + + return Array.from( + line.querySelectorAll('.karaoke-romaji-word[data-time][data-duration]'), + ).filter((element) => { + const start = Number.parseFloat(element.dataset.time ?? 'NaN'); + const duration = Number.parseFloat(element.dataset.duration ?? '0'); + if (!Number.isFinite(start)) { + return false; + } + + const end = start + duration; + return start < mainEnd && mainStart < end; + }); +}; + +const cancelSungAnimationCleanup = (element: HTMLElement): void => { + const existingTimeout = Number(element.dataset.sungCleanupTimeout); + if (existingTimeout) { + window.clearTimeout(existingTimeout); + delete element.dataset.sungCleanupTimeout; + } +}; + +const completeSungVisualCleanup = (element: HTMLElement): void => { + cancelSungAnimationCleanup(element); + element.style.removeProperty('--karaoke-swipe-delay'); + element.style.removeProperty('--karaoke-anim-delay'); + element.classList.remove(ANIMATING_CLASS); + element.classList.remove(PRE_ANIMATING_CLASS); + element.classList.remove(PAUSED_CLASS); +}; + +const getRemainingGlowDurationSec = ( + durationSec: number, + startTimeSec: number, + interpolatedTimeSec: number, +): number => { + const glowDurationSec = durationSec * GLOW_DURATION_MULTIPLIER; + const elapsedSec = Math.max(0, interpolatedTimeSec - startTimeSec); + return Math.max(0, glowDurationSec - elapsedSec); +}; + +const scheduleSungAnimationCleanup = ( + element: HTMLElement, + durationSec: number, + startTimeSec: number, + interpolatedTimeSec: number, +): void => { + const remainingSec = getRemainingGlowDurationSec( + durationSec, + startTimeSec, + interpolatedTimeSec, + ); + + if (remainingSec <= 0.05) { + completeSungVisualCleanup(element); + return; + } + + cancelSungAnimationCleanup(element); + + const timeoutId = window.setTimeout(() => { + delete element.dataset.sungCleanupTimeout; + completeSungVisualCleanup(element); + }, remainingSec * 1000); + + element.dataset.sungCleanupTimeout = String(timeoutId); +}; + +const clearElementAnimation = (element: HTMLElement): void => { + cancelSungAnimationCleanup(element); + element.style.removeProperty('--karaoke-swipe-delay'); + element.style.removeProperty('--karaoke-anim-delay'); + element.classList.remove(ANIMATING_CLASS); + element.classList.remove(PRE_ANIMATING_CLASS); + element.classList.remove(PAUSED_CLASS); +}; + +const setupElementAnimation = ( + element: HTMLElement, + time: number, + duration: number, + interpolatedTimeSec: number, +): void => { + const partTimeDelta = interpolatedTimeSec - time; + + element.classList.remove(ANIMATING_CLASS); + element.classList.remove(PAUSED_CLASS); + element.style.setProperty('--karaoke-swipe-delay', `${-partTimeDelta - duration * 0.1}s`); + element.style.setProperty('--karaoke-anim-delay', `${-partTimeDelta}s`); + element.classList.add(PRE_ANIMATING_CLASS); + + reflow(element); + + element.classList.add(ANIMATING_CLASS); +}; + +const syncRomajiAnimation = ( + mainElement: HTMLElement, + interpolatedTimeSec: number, + action: 'clear' | 'clearSung' | 'pause' | 'resume' | 'setup' | 'sung', +): void => { + for (const element of getRomajiCounterparts(mainElement)) { + const time = Number.parseFloat(element.dataset.time ?? '0'); + const duration = Number.parseFloat(element.dataset.duration ?? '0'); + const end = time + duration; + + switch (action) { + case 'clear': + clearElementAnimation(element); + if (interpolatedTimeSec < time) { + element.classList.remove('sung'); + } + break; + case 'clearSung': + element.classList.remove('sung'); + break; + case 'pause': + if (element.classList.contains(ANIMATING_CLASS)) { + element.classList.add(PAUSED_CLASS); + } + break; + case 'resume': + element.classList.remove(PAUSED_CLASS); + break; + case 'setup': + if ( + element.classList.contains(ANIMATING_CLASS) || + element.classList.contains('sung') || + interpolatedTimeSec < time || + interpolatedTimeSec >= end + ) { + break; + } + + setupElementAnimation(element, time, duration, interpolatedTimeSec); + break; + case 'sung': + if (interpolatedTimeSec >= end) { + element.classList.add('sung'); + scheduleSungAnimationCleanup(element, duration, time, interpolatedTimeSec); + } + break; + default: + break; + } + } +}; + +const clearLineRomajiState = (lineElement: HTMLElement): void => { + for (const element of lineElement.querySelectorAll('.karaoke-romaji-word')) { + clearElementAnimation(element); + element.classList.remove('sung'); + } +}; + +export const buildLyricsDataFromDom = ({ + container, + lineIdPrefix, + lyrics, +}: BuildLyricsDataOptions): LyricsData | null => { + const hasWordCues = lyricsHasWordCues(lyrics); + const lines: LineData[] = []; + + for (let index = 0; index < lyrics.length; index += 1) { + const lineElement = document.getElementById(`${lineIdPrefix}-${index}`); + if (!lineElement) { + continue; + } + + const line = lyrics[index]; + const startMs = getLyricLineStartMs(line); + const endMs = getLineEndMs(line); + const durationMs = Math.max(0, endMs - startMs); + const parts = hasWordCues && line.cueLines?.length ? collectLineParts(lineElement) : []; + + lines.push({ + accumulatedOffsetMs: 0, + animationStartTimeMs: Number.POSITIVE_INFINITY, + duration: durationMs / 1000, + element: lineElement, + hasWordCues: parts.length > 0, + height: -1, + isAnimating: false, + isAnimationPlayStatePlaying: false, + isScrolled: false, + isSelected: false, + lastAnimSetupAt: 0, + parts, + position: -1, + time: startMs / 1000, + }); + } + + if (!lines.length) { + return null; + } + + const allZero = lines.every((line) => line.time === 0); + let syncType: LyricsSyncType = allZero ? 'none' : 'synced'; + if (hasWordCues) { + syncType = 'richsync'; + } + + return { + container, + lines, + syncType, + }; +}; + +export const recalculateLinePositions = (lyricsData: LyricsData): void => { + const { container, lines } = lyricsData; + const containerRect = container.getBoundingClientRect(); + + for (const line of lines) { + const rect = line.element.getBoundingClientRect(); + line.position = rect.top - containerRect.top + container.scrollTop; + line.height = rect.height; + } +}; + +const clearPartAnimation = (part: PartData): void => { + cancelSungAnimationCleanup(part.element); + part.element.style.removeProperty('--karaoke-swipe-delay'); + part.element.style.removeProperty('--karaoke-anim-delay'); + part.element.classList.remove(ANIMATING_CLASS); + part.element.classList.remove(PRE_ANIMATING_CLASS); + part.element.classList.remove(PAUSED_CLASS); + part.animationStartTimeMs = Number.POSITIVE_INFINITY; + part.isAnimating = false; +}; + +const clearLineAnimation = (line: LineData): void => { + line.element.style.removeProperty('--karaoke-swipe-delay'); + line.element.style.removeProperty('--karaoke-anim-delay'); + line.element.classList.remove(LINE_ANIMATING_CLASS); + line.element.classList.remove(LINE_PRE_ANIMATING_CLASS); + line.element.classList.remove(PAUSED_CLASS); + line.animationStartTimeMs = Number.POSITIVE_INFINITY; +}; + +const clearWordSungState = (part: PartData): void => { + part.element.classList.remove('sung'); + syncRomajiAnimation(part.element, 0, 'clearSung'); +}; + +const markWordSung = (part: PartData, interpolatedTimeSec: number): void => { + part.animationStartTimeMs = Number.POSITIVE_INFINITY; + part.isAnimating = false; + part.element.classList.add('sung'); + scheduleSungAnimationCleanup(part.element, part.duration, part.time, interpolatedTimeSec); + syncRomajiAnimation(part.element, interpolatedTimeSec, 'sung'); +}; + +const setupPartAnimation = (part: PartData, interpolatedTimeSec: number, now: number): void => { + const partTimeDelta = interpolatedTimeSec - part.time; + + part.element.classList.remove(ANIMATING_CLASS); + part.element.classList.remove(PAUSED_CLASS); + part.element.style.setProperty( + '--karaoke-swipe-delay', + `${-partTimeDelta - part.duration * 0.1}s`, + ); + part.element.style.setProperty('--karaoke-anim-delay', `${-partTimeDelta}s`); + part.element.classList.add(PRE_ANIMATING_CLASS); + + reflow(part.element); + + part.element.classList.add(ANIMATING_CLASS); + part.animationStartTimeMs = now - partTimeDelta * 1000; + part.isAnimating = true; + syncRomajiAnimation(part.element, interpolatedTimeSec, 'setup'); +}; + +const setupLineAnimation = (line: LineData, interpolatedTimeSec: number, now: number): void => { + const timeDelta = interpolatedTimeSec - line.time; + + line.element.classList.remove(LINE_ANIMATING_CLASS); + line.element.classList.remove(PAUSED_CLASS); + line.element.style.setProperty('--karaoke-swipe-delay', `${-timeDelta - line.duration * 0.1}s`); + line.element.style.setProperty('--karaoke-anim-delay', `${-timeDelta}s`); + line.element.classList.add(LINE_PRE_ANIMATING_CLASS); + + reflow(line.element); + + line.element.classList.add(LINE_ANIMATING_CLASS); + line.animationStartTimeMs = now - timeDelta * 1000; + line.isAnimating = true; + line.lastAnimSetupAt = now; + line.isAnimationPlayStatePlaying = true; + line.accumulatedOffsetMs = 0; +}; + +const decaySkipScrolls = (state: ScrollState, now: number): void => { + let decayCount = 0; + + for (const decayTime of state.skipScrollsDecayTimes) { + if (decayTime > now) { + break; + } + decayCount += 1; + } + + if (decayCount > 0) { + state.skipScrollsDecayTimes = state.skipScrollsDecayTimes.slice(decayCount); + state.skipScrolls = Math.max(0, state.skipScrolls - decayCount); + } +}; + +const cancelActiveLyricsScroll = (ss: ScrollState): void => { + if (ss.cancelScroll) { + ss.cancelScroll(); + ss.cancelScroll = null; + } +}; + +export const animateLyricsScrollTo = ( + state: AnimEngineState, + scrollContainer: HTMLElement, + scrollPos: number, + smoothScroll = true, +): number => { + const ss = state.scroll; + const now = Date.now(); + const scrollTop = scrollContainer.scrollTop; + + cancelActiveLyricsScroll(ss); + + let useSmoothScroll = smoothScroll; + if (scrollTop === 0 && !ss.doneFirstInstantScroll) { + useSmoothScroll = false; + ss.doneFirstInstantScroll = true; + ss.nextScrollAllowedTime = 0; + } + + const delta = scrollPos - scrollTop; + + if (useSmoothScroll && Math.abs(delta) > 2) { + const durationMs = computeScrollDurationMs(delta); + ss.cancelScroll = animateScrollTop(scrollContainer, scrollPos); + ss.nextScrollAllowedTime = durationMs + now + 20; + ss.programmaticScrollUntil = now + durationMs + 50; + ss.scrollPos = scrollPos; + ss.skipScrolls += 1; + ss.skipScrollsDecayTimes.push(now + 2000); + return durationMs; + } + + scrollContainer.scrollTop = scrollPos; + ss.programmaticScrollUntil = now + 100; + ss.scrollPos = scrollPos; + ss.skipScrolls += 1; + ss.skipScrollsDecayTimes.push(now + 2000); + return 0; +}; + +const scrollToPosition = ( + state: AnimEngineState, + scrollContainer: HTMLElement, + scrollPos: number, + smoothScroll: boolean, +): void => { + animateLyricsScrollTo(state, scrollContainer, scrollPos, smoothScroll); +}; + +export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions): number => { + const now = Date.now(); + const { + currentTimeMs, + follow = true, + forceResync = false, + isPlaying, + smoothScroll = true, + } = opts; + const { eventCreationTime, lyricsData, onLineActive, scrollContainer } = opts; + + if (currentTimeMs === 0 && !isPlaying) { + return -1; + } + + const currentTimeSec = currentTimeMs / 1000; + const timeJumped = + forceResync || + Math.abs( + currentTimeSec - + state.lastTime - + (eventCreationTime - state.lastEventCreationTime) / 1000, + ) > TIME_JUMP_THRESHOLD; + + if (timeJumped) { + cancelActiveLyricsScroll(state.scroll); + + for (const lineData of lyricsData.lines) { + lineData.isAnimating = false; + lineData.isSelected = false; + lineData.isAnimationPlayStatePlaying = false; + + if (lineData.isScrolled) { + lineData.element.classList.remove(LINE_ACTIVE_CLASS); + lineData.isScrolled = false; + } + + if (lineData.hasWordCues) { + for (const part of lineData.parts) { + clearPartAnimation(part); + clearWordSungState(part); + } + clearLineRomajiState(lineData.element); + } else { + clearLineAnimation(lineData); + } + } + + state.scroll.pendingScroll = true; + } + + state.lastTime = currentTimeSec; + state.lastPlayState = isPlaying; + state.lastEventCreationTime = eventCreationTime; + + const timeOffsetSec = isPlaying ? (now - eventCreationTime) / 1000 : 0; + const playbackTimeSec = currentTimeSec + timeOffsetSec; + + const timingOffsetMs = + lyricsData.syncType === 'richsync' ? RICHSYNC_TIMING_OFFSET_MS : SYNC_TIMING_OFFSET_MS; + const interpolatedTimeSec = playbackTimeSec + timingOffsetMs / 1000; + const leadTimeSec = (opts.lineLeadTimeMs ?? DEFAULT_LINE_LEAD_TIME_MS) / 1000; + + const { lines, syncType } = lyricsData; + if (syncType === 'none') { + return -1; + } + + const scrollHeight = scrollContainer.getBoundingClientRect().height; + const activeElems: LineData[] = []; + let newLyricSelected = timeJumped; + let activeLineIndex = -1; + + for (let index = 0; index < lines.length; index += 1) { + const lineData = lines[index]; + const time = lineData.time; + const nextTime = + index + 1 < lines.length ? lines[index + 1].time : Number.POSITIVE_INFINITY; + + const isUpcoming = + playbackTimeSec >= time - leadTimeSec && + (playbackTimeSec < nextTime || playbackTimeSec < time + lineData.duration); + + if (isUpcoming) { + activeElems.push(lineData); + state.selectedElementIndex = index; + activeLineIndex = index; + + if (!lineData.isScrolled) { + newLyricSelected = true; + state.scroll.pendingScroll = true; + lineData.element.classList.add(LINE_ACTIVE_CLASS); + lineData.isScrolled = true; + onLineActive?.(index); + } + } else if (lineData.isScrolled) { + lineData.element.classList.remove(LINE_ACTIVE_CLASS); + lineData.isScrolled = false; + } + + const setUpEarly = isPlaying ? 2 : 0; + const effectiveEnd = Math.max(nextTime, time + lineData.duration + 0.05); + const isLineInRange = + interpolatedTimeSec + setUpEarly >= time && interpolatedTimeSec < effectiveEnd; + + if (isLineInRange) { + lineData.isSelected = true; + + if (!isPlaying) { + if (lineData.hasWordCues) { + for (const part of lineData.parts) { + if (part.isAnimating) { + part.element.classList.add(PAUSED_CLASS); + syncRomajiAnimation(part.element, interpolatedTimeSec, 'pause'); + } + } + } else if (lineData.isAnimating) { + lineData.element.classList.add(PAUSED_CLASS); + } + + lineData.isAnimationPlayStatePlaying = false; + } else { + if (lineData.hasWordCues) { + for (const part of lineData.parts) { + part.element.classList.remove(PAUSED_CLASS); + syncRomajiAnimation(part.element, interpolatedTimeSec, 'resume'); + } + } else { + lineData.element.classList.remove(PAUSED_CLASS); + } + + lineData.isAnimationPlayStatePlaying = true; + + if (!lineData.hasWordCues && !lineData.isAnimating) { + setupLineAnimation(lineData, interpolatedTimeSec, now); + } + } + + if (lineData.hasWordCues) { + for (const part of lineData.parts) { + const partEnd = part.time + part.duration; + + if (interpolatedTimeSec >= partEnd) { + if (!part.element.classList.contains('sung')) { + markWordSung(part, interpolatedTimeSec); + } + } else if (interpolatedTimeSec < part.time) { + if (part.isAnimating || part.element.classList.contains('sung')) { + clearPartAnimation(part); + clearWordSungState(part); + syncRomajiAnimation(part.element, interpolatedTimeSec, 'clear'); + } + } else if (isPlaying && !part.isAnimating) { + setupPartAnimation(part, interpolatedTimeSec, now); + } + } + } + } else if (lineData.isSelected) { + if (lineData.hasWordCues) { + for (const part of lineData.parts) { + if (interpolatedTimeSec >= part.time + part.duration) { + markWordSung(part, interpolatedTimeSec); + } else { + clearPartAnimation(part); + clearWordSungState(part); + syncRomajiAnimation(part.element, interpolatedTimeSec, 'clear'); + } + } + } else { + clearLineAnimation(lineData); + } + + lineData.isSelected = false; + lineData.isAnimating = false; + lineData.isAnimationPlayStatePlaying = false; + } + } + + const ss = state.scroll; + const scrollPausedByUser = ss.scrollResumeTime >= now; + const canAutoscroll = + follow && (!scrollPausedByUser || ss.pendingScroll || ss.scrollPos === -1); + + if (canAutoscroll) { + if (activeElems.length === 0 && lines.length > 0) { + activeElems.push(lines[0]); + } + + state.lastActiveElements = activeElems.filter((entry) => playbackTimeSec >= entry.time); + + if (activeElems.length > 0) { + const scrollPosOffset = scrollHeight * DEFAULT_SCROLL_POS_RATIO; + const lastActive = activeElems[activeElems.length - 1]; + const useLastActiveOnly = newLyricSelected || activeElems.length > 1; + const scrollLines = useLastActiveOnly ? [lastActive] : activeElems; + const positions = scrollLines + .filter( + (lineData, index) => + playbackTimeSec < + lineData.time + lineData.duration - DEFAULT_ENDING_THRESHOLD || + index === scrollLines.length - 1, + ) + .map((lineData) => lineData.position + lineData.height / 2); + + if (positions.length > 0 && positions.every((pos) => pos >= 0)) { + const avgPos = positions.reduce((sum, pos) => sum + pos, 0) / positions.length; + let scrollPos = avgPos - scrollPosOffset; + scrollPos = Math.min(scrollPos, scrollLines[0].position); + scrollPos = Math.max( + scrollPos, + lastActive.position - scrollHeight + lastActive.height, + ); + scrollPos = Math.min(scrollPos, lastActive.position); + scrollPos = Math.max(0, scrollPos); + + const shouldScroll = + ss.wasUserScrolling || newLyricSelected || ss.queuedScroll || ss.pendingScroll; + const canScrollNow = now > ss.nextScrollAllowedTime; + + if (shouldScroll) { + if (canScrollNow) { + ss.queuedScroll = false; + ss.pendingScroll = false; + scrollToPosition(state, scrollContainer, scrollPos, smoothScroll); + } else { + ss.queuedScroll = true; + } + } + } + } + } + + decaySkipScrolls(ss, now); + + if (ss.wasUserScrolling && ss.scrollResumeTime < now) { + ss.wasUserScrolling = false; + } + + return activeLineIndex; +}; + +export const handleLyricsUserScroll = (state: AnimEngineState, pauseDurationMs = 3000): void => { + state.scroll.wasUserScrolling = true; + state.scroll.scrollResumeTime = Date.now() + pauseDurationMs; +}; + +export const resumeLyricsAutoscroll = (state: AnimEngineState): void => { + state.scroll.wasUserScrolling = false; + state.scroll.scrollResumeTime = 0; +}; + +export const shouldSkipLyricsScrollEvent = (state: AnimEngineState): boolean => { + if (Date.now() < state.scroll.programmaticScrollUntil) { + return true; + } + + if (state.scroll.skipScrolls > 0) { + state.scroll.skipScrolls -= 1; + return true; + } + + return false; +}; + +export const resetLyricsAnimationDom = (lyricsData: LyricsData | null): void => { + if (!lyricsData) { + return; + } + + for (const line of lyricsData.lines) { + line.element.classList.remove( + LINE_ACTIVE_CLASS, + LINE_ANIMATING_CLASS, + LINE_PRE_ANIMATING_CLASS, + 'singing', + 'complete', + ); + line.isScrolled = false; + line.isSelected = false; + line.isAnimating = false; + + for (const part of line.parts) { + clearPartAnimation(part); + clearWordSungState(part); + } + + clearLineRomajiState(line.element); + clearLineAnimation(line); + } +}; diff --git a/src/renderer/features/lyrics/hooks/use-furigana-lyrics.ts b/src/renderer/features/lyrics/hooks/use-furigana-lyrics.ts index 9d28451f0..91da48bac 100644 --- a/src/renderer/features/lyrics/hooks/use-furigana-lyrics.ts +++ b/src/renderer/features/lyrics/hooks/use-furigana-lyrics.ts @@ -1,10 +1,78 @@ import { useQuery } from '@tanstack/react-query'; import isElectron from 'is-electron'; -import { LyricsResponse, SynchronizedLyricsArray } from '/@/shared/types/domain-types'; +import { + alignFuriganaToWordCues, + alignRomajiTokensToWordCues, + LyricTextToken, + RomajiToken, +} from '/@/renderer/features/lyrics/api/lyric-conversion'; +import { LyricsResponse, SyncedCueLine, SynchronizedLyrics } from '/@/shared/types/domain-types'; const lyricsApi = isElectron() ? window.api.lyrics : null; +const convertSyncedLyricsFurigana = async ( + lyrics: SynchronizedLyrics, +): Promise => { + if (!lyricsApi) { + return lyrics; + } + + return Promise.all( + lyrics.map(async (line) => ({ + ...line, + cueLines: line.cueLines + ? await Promise.all( + line.cueLines.map(async (cueLine) => { + const tokens = (await lyricsApi.parseLyricsTextTokens( + cueLine.value, + )) as LyricTextToken[]; + const alignedWords = cueLine.words.length + ? await alignFuriganaToWordCues( + cueLine.value, + cueLine.words, + tokens, + (text) => lyricsApi.convertFuriganaFragment(text), + ) + : cueLine.words; + return { + ...cueLine, + value: await lyricsApi.convertFurigana(cueLine.value), + words: alignedWords ?? cueLine.words, + }; + }), + ) + : undefined, + text: await lyricsApi.convertFurigana(line.text), + })), + ); +}; + +const convertSyncedLyricsRomaji = async ( + lyrics: SynchronizedLyrics, + convert: (text: string) => Promise, +): Promise => + Promise.all( + lyrics.map(async (line) => ({ + ...line, + cueLines: line.cueLines + ? await Promise.all( + line.cueLines.map(async (cueLine) => ({ + ...cueLine, + value: await convert(cueLine.value), + words: await Promise.all( + cueLine.words.map(async (word) => ({ + ...word, + text: await convert(word.text), + })), + ), + })), + ) + : undefined, + text: await convert(line.text), + })), + ); + export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, enabled: boolean) => { return useQuery({ enabled: enabled && !!lyrics && !!lyricsApi, @@ -13,15 +81,12 @@ export const useFuriganaLyrics = (lyrics: LyricsResponse | null | undefined, ena if (typeof lyrics === 'string') { return await lyricsApi.convertFurigana(lyrics); - } else if (Array.isArray(lyrics)) { - const converted = await Promise.all( - lyrics.map(async ([time, line]) => [ - time, - await lyricsApi.convertFurigana(line), - ]), - ); - return converted as SynchronizedLyricsArray; } + + if (Array.isArray(lyrics)) { + return convertSyncedLyricsFurigana(lyrics); + } + return lyrics; }, queryKey: ['furigana', lyrics], @@ -37,15 +102,80 @@ export const useRomajiLyrics = (lyrics: LyricsResponse | null | undefined, enabl if (typeof lyrics === 'string') { return await lyricsApi.convertRomaji(lyrics); - } else if (Array.isArray(lyrics)) { - const converted = await Promise.all( - lyrics.map(async ([time, line]) => [time, await lyricsApi.convertRomaji(line)]), - ); - return converted as SynchronizedLyricsArray; } + + if (Array.isArray(lyrics)) { + return convertSyncedLyricsRomaji(lyrics, (text) => lyricsApi.convertRomaji(text)); + } + return lyrics; }, queryKey: ['romaji', lyrics], staleTime: Infinity, }); }; + +export type SyncedRomajiLyrics = (null | SyncedCueLine[])[]; + +const buildSyncedRomajiLine = async ( + cueLines: SyncedCueLine[], +): Promise => { + const romajiCueLines: SyncedCueLine[] = []; + + for (const cueLine of cueLines) { + if (!cueLine.words.length) { + romajiCueLines.push({ ...cueLine }); + continue; + } + + if (!lyricsApi) { + return null; + } + + const tokens = (await lyricsApi.convertRomajiTokens(cueLine.value)) as RomajiToken[]; + const alignedWords = alignRomajiTokensToWordCues(cueLine.value, cueLine.words, tokens); + + if (!alignedWords) { + return null; + } + + romajiCueLines.push({ + ...cueLine, + words: alignedWords, + }); + } + + return romajiCueLines; +}; + +export const useSyncedRomajiLyrics = ( + lyrics: null | SynchronizedLyrics | undefined, + enabled: boolean, +) => { + return useQuery({ + enabled: enabled && !!lyrics && !!lyricsApi, + queryFn: async (): Promise => { + if (!lyrics || !lyricsApi || !enabled) { + return null; + } + + const result: SyncedRomajiLyrics = []; + + for (const line of lyrics) { + if ( + !line.cueLines?.length || + !line.cueLines.some((cueLine) => cueLine.words.length) + ) { + result.push(null); + continue; + } + + result.push(await buildSyncedRomajiLine(line.cueLines)); + } + + return result; + }, + queryKey: ['romaji-synced', lyrics], + staleTime: Infinity, + }); +}; diff --git a/src/renderer/features/lyrics/hooks/use-lyrics-animation-engine.ts b/src/renderer/features/lyrics/hooks/use-lyrics-animation-engine.ts new file mode 100644 index 000000000..f79839b7c --- /dev/null +++ b/src/renderer/features/lyrics/hooks/use-lyrics-animation-engine.ts @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { + AnimEngineState, + buildLyricsDataFromDom, + BuildLyricsDataOptions, + createAnimEngineState, + LyricsData, + recalculateLinePositions, + resetAnimEngine, + resetLyricsAnimationDom, + resumeLyricsAutoscroll, + tickLyricsAnimation, +} from '/@/renderer/features/lyrics/hooks/lyrics-animation-engine'; +import { SynchronizedLyrics } from '/@/shared/types/domain-types'; +import '/@/renderer/features/lyrics/styles/karaoke-animation.css'; + +export interface UseLyricsAnimationEngineOptions { + animStateRef?: React.MutableRefObject; + containerRef: React.RefObject; + enabled?: boolean; + followRef?: React.RefObject; + lineIdPrefix: 'karaoke-line' | 'lyric'; + lineLeadTimeMsRef?: React.RefObject; + lyrics: SynchronizedLyrics; + onLineActive?: (lineIndex: number) => void; + scrollContainerId: string; +} + +export const useLyricsAnimationEngine = ({ + animStateRef: externalAnimStateRef, + containerRef, + enabled = true, + followRef, + lineIdPrefix, + lineLeadTimeMsRef, + lyrics, + onLineActive, + scrollContainerId, +}: UseLyricsAnimationEngineOptions) => { + const internalAnimStateRef = useRef(createAnimEngineState()); + const animStateRef = externalAnimStateRef ?? internalAnimStateRef; + const lyricsDataRef = useRef(null); + const onLineActiveRef = useRef(onLineActive); + + useEffect(() => { + onLineActiveRef.current = onLineActive; + }, [onLineActive]); + + const rebuildLyricsData = useCallback(() => { + const container = containerRef.current; + if (!container || !enabled) { + lyricsDataRef.current = null; + return null; + } + + const options: BuildLyricsDataOptions = { + container, + lineIdPrefix, + lyrics, + }; + + const data = buildLyricsDataFromDom(options); + lyricsDataRef.current = data; + + if (data) { + recalculateLinePositions(data); + animStateRef.current.scroll.wasUserScrolling = true; + } + + return data; + // eslint-disable-next-line react-hooks/exhaustive-deps -- animStateRef is read via .current at call time + }, [containerRef, enabled, lineIdPrefix, lyrics]); + + const reset = useCallback(() => { + resetAnimEngine(animStateRef.current); + resetLyricsAnimationDom(lyricsDataRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps -- animStateRef is read via .current at call time + }, []); + + const recalculatePositions = useCallback(() => { + if (lyricsDataRef.current) { + recalculateLinePositions(lyricsDataRef.current); + animStateRef.current.scroll.wasUserScrolling = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- animStateRef is read via .current at call time + }, []); + + const tick = useCallback( + ( + currentTimeMs: number, + isPlaying: boolean, + options?: { eventCreationTime?: number; forceResync?: boolean }, + ): number => { + if (!enabled) { + return -1; + } + + let lyricsData = lyricsDataRef.current; + if (!lyricsData) { + lyricsData = rebuildLyricsData(); + } + + if (!lyricsData) { + return -1; + } + + const scrollContainer = + document.getElementById(scrollContainerId) ?? containerRef.current ?? undefined; + + if (!scrollContainer) { + return -1; + } + + return tickLyricsAnimation(animStateRef.current, { + currentTimeMs, + eventCreationTime: options?.eventCreationTime ?? Date.now(), + follow: followRef?.current ?? true, + forceResync: options?.forceResync ?? false, + isPlaying, + lineLeadTimeMs: lineLeadTimeMsRef?.current, + lyricsData, + onLineActive: onLineActiveRef.current, + scrollContainer, + smoothScroll: true, + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- animStateRef, containerRef, followRef, lineLeadTimeMsRef are read via .current at call time + [enabled, rebuildLyricsData, scrollContainerId], + ); + + const resumeAutoscroll = useCallback(() => { + resumeLyricsAutoscroll(animStateRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps -- animStateRef is read via .current at call time + }, []); + + useEffect(() => { + reset(); + const frame = requestAnimationFrame(() => { + rebuildLyricsData(); + }); + + return () => { + cancelAnimationFrame(frame); + reset(); + }; + }, [lyrics, enabled, rebuildLyricsData, reset]); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const observer = new ResizeObserver(() => { + recalculatePositions(); + }); + + observer.observe(container); + + return () => { + observer.disconnect(); + }; + }, [containerRef, recalculatePositions]); + + return { + animStateRef, + lyricsDataRef, + rebuildLyricsData, + recalculatePositions, + reset, + resumeAutoscroll, + tick, + }; +}; diff --git a/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts b/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts new file mode 100644 index 000000000..3c61906f8 --- /dev/null +++ b/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts @@ -0,0 +1,238 @@ +import isElectron from 'is-electron'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { + animateLyricsScrollTo, + createAnimEngineState, + handleLyricsUserScroll, + resumeLyricsAutoscroll, + shouldSkipLyricsScrollEvent, +} from '/@/renderer/features/lyrics/hooks/lyrics-animation-engine'; +import { + useLyricsDisplaySettings, + useLyricsSettings, + usePlaybackType, + usePlayerActions, +} from '/@/renderer/store'; +import { SynchronizedLyrics } from '/@/shared/types/domain-types'; +import { PlayerType } from '/@/shared/types/types'; + +const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; +const utils = isElectron() ? window.api.utils : null; +const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null; + +export const LYRICS_SCROLL_CONTAINER_ID = 'sychronized-lyrics-scroll-container'; + +export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: number) => { + const playbackType = usePlaybackType(); + const lyricsSettings = useLyricsSettings(); + const displaySettings = useLyricsDisplaySettings(settingsKey); + const { mediaSeekToTimestamp } = usePlayerActions(); + + const settings = useMemo( + () => ({ + ...lyricsSettings, + fontSize: + displaySettings.fontSize && displaySettings.fontSize !== 0 + ? displaySettings.fontSize + : 24, + gap: displaySettings.gap && displaySettings.gap !== 0 ? displaySettings.gap : 24, + opacityNonActive: displaySettings.opacityNonActive, + scaleNonActive: + displaySettings.scaleNonActive && displaySettings.scaleNonActive !== 0 + ? displaySettings.scaleNonActive + : 0.95, + }), + [displaySettings, lyricsSettings], + ); + + const effectiveOffsetMs = offsetMs ?? 0; + const delayMsRef = useRef(effectiveOffsetMs); + const followRef = useRef(settings.follow); + const lineLeadTimeMsRef = useRef(settings.lineLeadTimeMs); + const userScrollingRef = useRef(false); + const scrollTimeoutRef = useRef>(null); + const containerRef = useRef(null); + const programmaticScrollRef = useRef(false); + const programmaticScrollTimeoutRef = useRef>(null); + const lyricRef = useRef(null); + const scrollAnimStateRef = useRef(createAnimEngineState()); + + const handleSeek = useCallback( + (time: number) => { + if (playbackType === PlayerType.LOCAL && mpvPlayer) { + mpvPlayer.seekTo(time); + } else { + mpris?.updateSeek(time); + mediaSeekToTimestamp(time); + } + }, + [mediaSeekToTimestamp, playbackType], + ); + + const handleLineClick = useCallback( + (event: React.MouseEvent) => { + const target = (event.target as HTMLElement).closest('[data-lyric-time]'); + if (!target) { + return; + } + + const time = Number((target as HTMLElement).dataset.lyricTime); + if (time >= 0 && Number.isFinite(time)) { + handleSeek(time / 1000); + } + }, + [handleSeek], + ); + + const scrollToLine = useCallback( + (_index: number, lineSelector: string, anchorSelector?: string) => { + const doc = document.getElementById(LYRICS_SCROLL_CONTAINER_ID) as HTMLElement; + const lineElement = document.querySelector(lineSelector) as HTMLElement | null; + const anchorElement = anchorSelector + ? (document.querySelector(anchorSelector) as HTMLElement | null) + : null; + const scrollTarget = anchorElement ?? lineElement; + + if (!followRef.current || userScrollingRef.current || !scrollTarget || !doc) { + return; + } + + const containerRect = doc.getBoundingClientRect(); + const targetRect = scrollTarget.getBoundingClientRect(); + const targetCenterY = targetRect.top + targetRect.height / 2; + const containerCenterY = containerRect.top + containerRect.height / 2; + const scrollTop = doc.scrollTop + targetCenterY - containerCenterY; + + programmaticScrollRef.current = true; + const durationMs = animateLyricsScrollTo(scrollAnimStateRef.current, doc, scrollTop); + + if (programmaticScrollTimeoutRef.current) { + clearTimeout(programmaticScrollTimeoutRef.current); + } + + programmaticScrollTimeoutRef.current = setTimeout( + () => { + programmaticScrollRef.current = false; + }, + durationMs > 0 ? durationMs + 50 : 150, + ); + }, + [], + ); + + const resumeAutoscroll = useCallback(() => { + userScrollingRef.current = false; + resumeLyricsAutoscroll(scrollAnimStateRef.current); + + const doc = document.getElementById(LYRICS_SCROLL_CONTAINER_ID) as HTMLElement; + doc?.classList.remove('lyrics-user-scrolling'); + }, []); + + const containerStyle = useMemo( + () => + ({ + '--lyric-opacity': settings.opacityNonActive, + '--lyric-scale': settings.scaleNonActive, + '--lyric-scale-origin': settings.alignment, + gap: `${settings.gap}px`, + }) as React.CSSProperties, + [settings.alignment, settings.gap, settings.opacityNonActive, settings.scaleNonActive], + ); + + const hideScrollbar = useCallback(() => { + const doc = document.getElementById(LYRICS_SCROLL_CONTAINER_ID) as HTMLElement; + doc?.classList.add('hide-scrollbar'); + }, []); + + const showScrollbar = useCallback(() => { + const doc = document.getElementById(LYRICS_SCROLL_CONTAINER_ID) as HTMLElement; + doc?.classList.remove('hide-scrollbar'); + }, []); + + useEffect(() => { + followRef.current = settings.follow; + }, [settings.follow]); + + useEffect(() => { + lineLeadTimeMsRef.current = settings.lineLeadTimeMs; + }, [settings.lineLeadTimeMs]); + + useEffect(() => { + const newOffset = offsetMs ?? 0; + if (delayMsRef.current === newOffset) { + return; + } + + delayMsRef.current = newOffset; + }, [offsetMs]); + + useEffect(() => { + const container = + containerRef.current || + (document.getElementById(LYRICS_SCROLL_CONTAINER_ID) as HTMLElement); + if (!container) return; + + const handleScroll = () => { + if (shouldSkipLyricsScrollEvent(scrollAnimStateRef.current)) { + return; + } + + if (programmaticScrollRef.current) { + if (programmaticScrollTimeoutRef.current) { + clearTimeout(programmaticScrollTimeoutRef.current); + } + + programmaticScrollTimeoutRef.current = setTimeout(() => { + programmaticScrollRef.current = false; + }, 150); + + return; + } + + userScrollingRef.current = true; + handleLyricsUserScroll(scrollAnimStateRef.current); + container.classList.add('lyrics-user-scrolling'); + + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + + scrollTimeoutRef.current = setTimeout(() => { + userScrollingRef.current = false; + container.classList.remove('lyrics-user-scrolling'); + }, 3000); + }; + + container.addEventListener('scroll', handleScroll, { passive: true }); + + return () => { + container.removeEventListener('scroll', handleScroll); + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + + if (programmaticScrollTimeoutRef.current) { + clearTimeout(programmaticScrollTimeoutRef.current); + } + }; + }, []); + + return { + containerRef, + containerStyle, + delayMsRef, + followRef, + handleLineClick, + handleSeek, + hideScrollbar, + lineLeadTimeMsRef, + lyricRef, + resumeAutoscroll, + scrollAnimStateRef, + scrollToLine, + settings, + showScrollbar, + userScrollingRef, + }; +}; diff --git a/src/renderer/features/lyrics/karaoke-lyric-line.module.css b/src/renderer/features/lyrics/karaoke-lyric-line.module.css new file mode 100644 index 000000000..6606e2acc --- /dev/null +++ b/src/renderer/features/lyrics/karaoke-lyric-line.module.css @@ -0,0 +1,76 @@ +.karaoke-line { + width: 100%; + max-width: 100%; + padding: 0 1rem; + font-weight: 600; + line-height: 1.2; + color: var(--theme-colors-foreground); + word-break: normal; + overflow-wrap: break-word; + opacity: var(--lyric-opacity); + transform: scale(var(--lyric-scale)); + transform-origin: var(--lyric-scale-origin) center; + transition: + filter calc(var(--karaoke-line-duration, 1s) / 3) 0.4s, + opacity calc(var(--karaoke-line-duration, 1s) / 2) 0.166s, + transform 0.166s var(--karaoke-anim-delay, 0s); + will-change: transform; +} + +.karaoke-line:global(.lyrics-line-active), +.karaoke-line:global(.lyrics-line-animating), +.karaoke-line:global(.lyrics-line-pre-animating) { + opacity: 1 !important; + filter: blur(0); + transform: scale(1); +} + +.karaoke-line:global(.lyrics-line-animating) { + transition-delay: var(--karaoke-anim-delay, 0s); +} + +.karaoke-line:global(.synchronized) { + cursor: pointer; +} + +.karaoke-word { + display: inline; +} + +.romaji-line { + display: block; + max-width: 100%; + font-size: 0.8em; + font-weight: 600; + overflow-wrap: break-word; + white-space: normal; +} + +.agent-line { + width: 100%; + max-width: 100%; + text-align: inherit; +} + +.agent-badge { + display: block; + margin-bottom: 0.15rem; + font-size: 0.65em; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.7; +} + +.agent-text { + display: block; + width: 100%; + max-width: 100%; + overflow-wrap: break-word; + white-space: normal; +} + +.agent-line[data-agent-role='bg'] .agent-badge, +.agent-line[data-agent-role='group'] .agent-badge { + font-style: italic; +} diff --git a/src/renderer/features/lyrics/karaoke-lyric-line.tsx b/src/renderer/features/lyrics/karaoke-lyric-line.tsx new file mode 100644 index 000000000..f1529b316 --- /dev/null +++ b/src/renderer/features/lyrics/karaoke-lyric-line.tsx @@ -0,0 +1,219 @@ +import clsx from 'clsx'; +import { ComponentPropsWithoutRef, memo, useMemo } from 'react'; + +import styles from './karaoke-lyric-line.module.css'; + +import { testRtl } from '/@/renderer/features/lyrics/api/lyrics-rtl'; +import { splitWordCues } from '/@/renderer/features/lyrics/api/split-word-cue'; +import { sanitize } from '/@/renderer/utils/sanitize'; +import { Box } from '/@/shared/components/box/box'; +import { Stack } from '/@/shared/components/stack/stack'; +import { LyricAgent, SyncedCueLine } from '/@/shared/types/domain-types'; + +const LONG_WORD_THRESHOLD_MS = 1500; +const FURIGANA_HTML_RE = /]/i; + +const hasFuriganaHtml = (text: string): boolean => FURIGANA_HTML_RE.test(text); + +interface KaraokeLyricLineProps extends ComponentPropsWithoutRef<'div'> { + agents?: LyricAgent[]; + alignment: 'center' | 'left' | 'right'; + cueLines: SyncedCueLine[]; + fontSize: number; + lineIndex: number; + romajiCueLines?: null | SyncedCueLine[]; + romajiText?: null | string; + text?: string; + translatedText?: null | string; +} + +type WordSpanVariant = 'main' | 'romaji'; + +const getAgentLabel = (agents: LyricAgent[] | undefined, agentId?: string): string | undefined => { + if (!agentId || !agents?.length) { + return undefined; + } + + const agent = agents.find((entry) => entry.id === agentId); + return agent?.name ?? agent?.role; +}; + +const renderWordSpans = ( + cueLine: SyncedCueLine, + lineIndex: number, + cueLineIndex: number, + isBackground: boolean, + variant: WordSpanVariant = 'main', +) => { + const isRomaji = variant === 'romaji'; + const idPrefix = isRomaji ? 'karaoke-romaji' : 'karaoke'; + + if (!cueLine.words.length) { + return ( + + ); + } + + const splitWords = + isRomaji || cueLine.words.some((word) => hasFuriganaHtml(word.text)) + ? cueLine.words + : splitWordCues(cueLine.words); + let wordCounter = 0; + + return splitWords.map((word) => { + const durationMs = word.endMs - word.startMs; + const durationSec = durationMs / 1000; + const timeSec = word.startMs / 1000; + const isRtl = testRtl(word.text); + const isZeroDuration = durationMs <= 0; + const hasFurigana = !isRomaji && hasFuriganaHtml(word.text); + const sanitizedHtml = sanitize(word.text); + const currentWordIndex = wordCounter; + wordCounter += 1; + + const wordClassName = clsx( + styles.karaokeWord, + 'karaoke-word', + isRomaji && 'karaoke-romaji-word', + isRtl && 'karaoke-rtl', + isBackground && 'karaoke-bg-vocal', + isZeroDuration && 'karaoke-zero-dur', + hasFurigana && 'karaoke-furigana-word', + ); + + const wordKey = `${word.startMs}-${currentWordIndex}`; + const wordProps = { + className: wordClassName, + 'data-duration': String(durationSec), + 'data-lyric-time': cueLine.startMs, + 'data-time': String(timeSec), + 'data-word-start': word.startMs, + id: `${idPrefix}-${lineIndex}-cue-${cueLineIndex}-word-${currentWordIndex}`, + style: { + '--karaoke-duration': `${durationMs}ms`, + } as React.CSSProperties, + ...(durationMs > LONG_WORD_THRESHOLD_MS ? { 'data-long-word': true as const } : {}), + }; + + if (hasFurigana) { + return ( + + + + + ); + } + + return ( + + ); + }); +}; + +export const KaraokeLyricLine = memo( + ({ + agents, + alignment, + className, + cueLines, + fontSize, + lineIndex, + romajiCueLines, + romajiText, + translatedText, + ...props + }: KaraokeLyricLineProps) => { + const style = useMemo( + () => ({ + fontSize, + textAlign: alignment, + }), + [fontSize, alignment], + ); + + const hasSyncedRomaji = romajiCueLines != null; + + return ( + + + {cueLines.map((cueLine, cueLineIndex) => { + const agentLabel = getAgentLabel(agents, cueLine.agentId); + const agent = agents?.find((entry) => entry.id === cueLine.agentId); + const isBackground = agent?.role === 'bg' || agent?.role === 'group'; + const romajiCueLine = romajiCueLines?.[cueLineIndex]; + + return ( +
+ {agentLabel && ( + {agentLabel} + )} + + {renderWordSpans( + cueLine, + lineIndex, + cueLineIndex, + isBackground, + 'main', + )} + + {romajiCueLine && ( + + {renderWordSpans( + romajiCueLine, + lineIndex, + cueLineIndex, + false, + 'romaji', + )} + + )} +
+ ); + })} + {!hasSyncedRomaji && romajiText && ( + + )} + {translatedText && ( + + )} +
+
+ ); + }, +); + +KaraokeLyricLine.displayName = 'KaraokeLyricLine'; diff --git a/src/renderer/features/lyrics/lyric-line.module.css b/src/renderer/features/lyrics/lyric-line.module.css index 8d9d97a27..450ada65a 100644 --- a/src/renderer/features/lyrics/lyric-line.module.css +++ b/src/renderer/features/lyrics/lyric-line.module.css @@ -8,16 +8,25 @@ transform: scale(var(--lyric-scale)); transform-origin: var(--lyric-scale-origin) center; transition: - opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1), - transform 0.6s cubic-bezier(0.16, 1, 0.3, 1); + filter calc(var(--karaoke-line-duration, 1s) / 3) 0.4s, + opacity calc(var(--karaoke-line-duration, 1s) / 2) 0.166s, + transform 0.166s var(--karaoke-anim-delay, 0s); will-change: transform; } +.lyric-line:global(.lyrics-line-active), +.lyric-line:global(.lyrics-line-animating), +.lyric-line:global(.lyrics-line-pre-animating), .lyric-line:global(.active) { opacity: 1 !important; + filter: blur(0); transform: scale(1); } +.lyric-line:global(.lyrics-line-animating) { + transition-delay: var(--karaoke-anim-delay, 0s); +} + .lyric-line:global(.unsynchronized) { opacity: 1; } diff --git a/src/renderer/features/lyrics/lyric-line.tsx b/src/renderer/features/lyrics/lyric-line.tsx index cc46396df..270b1f031 100644 --- a/src/renderer/features/lyrics/lyric-line.tsx +++ b/src/renderer/features/lyrics/lyric-line.tsx @@ -11,7 +11,7 @@ interface LyricLineProps extends ComponentPropsWithoutRef<'div'> { alignment: 'center' | 'left' | 'right'; fontSize: number; romajiText?: null | string; - text: string; + text?: string; translatedText?: null | string; } @@ -25,7 +25,7 @@ export const LyricLine = memo( translatedText, ...props }: LyricLineProps) => { - const lines = useMemo(() => text.split('_BREAK_'), [text]); + const lines = useMemo(() => (text ?? '').split('_BREAK_'), [text]); const style = useMemo( () => ({ diff --git a/src/renderer/features/lyrics/lyrics-actions.tsx b/src/renderer/features/lyrics/lyrics-actions.tsx index 9d488b3d9..9f75dd96e 100644 --- a/src/renderer/features/lyrics/lyrics-actions.tsx +++ b/src/renderer/features/lyrics/lyrics-actions.tsx @@ -14,30 +14,42 @@ import { LyricsOverride } from '/@/shared/types/domain-types'; interface LyricsActionsProps { hasLyrics: boolean; + hasPronunciationLayer?: boolean; + hasTranslationLayer?: boolean; index: number; languages: { label: string; value: string }[]; offsetMs: number; onExportLyrics: () => void; onRemoveLyric: () => void; onSearchOverride: (params: LyricsOverride) => void; + onTogglePronunciationLayer?: () => void; + onToggleTranslationLayer?: () => void; onTranslateLyric?: () => void; onUpdateOffset: (offsetMs: number) => void; setIndex: (idx: number) => void; settingsKey?: string; + showPronunciationLayer?: boolean; + showTranslationLayer?: boolean; synced?: boolean; } export const LyricsActions = ({ hasLyrics, + hasPronunciationLayer = false, + hasTranslationLayer = false, index, languages, offsetMs, onExportLyrics, onRemoveLyric, onSearchOverride, + onTogglePronunciationLayer, + onToggleTranslationLayer, onTranslateLyric, onUpdateOffset, setIndex, + showPronunciationLayer = false, + showTranslationLayer = false, }: LyricsActionsProps) => { const { t } = useTranslation(); const currentSong = usePlayerSong(); @@ -134,16 +146,38 @@ export const LyricsActions = ({
- {isDesktop && sources.length && onTranslateLyric ? ( - - ) : null} + + {hasTranslationLayer && onToggleTranslationLayer ? ( + + ) : null} + {hasPronunciationLayer && onTogglePronunciationLayer ? ( + + ) : null} + {isDesktop && sources.length && onTranslateLyric && !hasTranslationLayer ? ( + + ) : null} +
diff --git a/src/renderer/features/lyrics/lyrics.module.css b/src/renderer/features/lyrics/lyrics.module.css index fe3615a55..609b86e21 100644 --- a/src/renderer/features/lyrics/lyrics.module.css +++ b/src/renderer/features/lyrics/lyrics.module.css @@ -38,9 +38,11 @@ .lyrics-container { position: relative; display: flex; + flex-direction: column; width: 100%; min-width: 0; height: 100%; + min-height: 0; overflow: hidden; &:hover { @@ -53,8 +55,12 @@ .scroll-container { position: relative; z-index: 1; + display: flex; + flex: 1; + flex-direction: column; width: 100%; - height: 100%; + min-height: 0; + overflow: hidden; text-align: center; mask-image: linear-gradient( 180deg, @@ -63,6 +69,12 @@ rgb(0 0 0 / 100%) 85%, transparent 95% ); + + > * { + flex: 1; + min-width: 0; + min-height: 0; + } } .settings-icon { diff --git a/src/renderer/features/lyrics/lyrics.tsx b/src/renderer/features/lyrics/lyrics.tsx index 6e28a38a4..1a58f9c42 100644 --- a/src/renderer/features/lyrics/lyrics.tsx +++ b/src/renderer/features/lyrics/lyrics.tsx @@ -13,12 +13,20 @@ import { lyricsQueries, type LyricsQueryResult, } from '/@/renderer/features/lyrics/api/lyrics-api'; +import { + formatStructuredLyricLabel, + getLyricLineText, + getLyricsLayers, + lyricsHasWordCues, +} from '/@/renderer/features/lyrics/api/lyrics-utils'; import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form'; import { useFuriganaLyrics, useRomajiLyrics, + useSyncedRomajiLyrics, } from '/@/renderer/features/lyrics/hooks/use-furigana-lyrics'; import { LyricsActions } from '/@/renderer/features/lyrics/lyrics-actions'; +import { SynchronizedKaraokeLyrics } from '/@/renderer/features/lyrics/synchronized-karaoke-lyrics'; import { SynchronizedLyrics, SynchronizedLyricsProps, @@ -64,6 +72,8 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' const [index, setIndexState] = useState(0); const [translatedLyrics, setTranslatedLyrics] = useState(null); const [showTranslation, setShowTranslation] = useState(false); + const [showTranslationLayer, setShowTranslationLayer] = useState(false); + const [showPronunciationLayer, setShowPronunciationLayer] = useState(false); const [pendingSongId, setPendingSongId] = useState(currentSong?.id); const lyricsFetchTimeoutRef = useRef | undefined>(undefined); const previousSongIdRef = useRef(currentSong?.id); @@ -125,6 +135,14 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' const { data: furiganaConvertedLyrics } = useFuriganaLyrics(lyrics?.lyrics, !!enableFurigana); const { data: romajiConvertedLyrics } = useRomajiLyrics(lyrics?.lyrics, !!enableRomaji); + const rawSyncedLyrics = useMemo(() => { + if (!synced || !lyrics || !('lyrics' in lyrics) || !Array.isArray(lyrics.lyrics)) { + return null; + } + + return lyrics.lyrics; + }, [lyrics, synced]); + const displayLyrics = useMemo(() => { if (isLyricsDisabled || !lyrics) return null; if (enableFurigana && furiganaConvertedLyrics) { @@ -138,7 +156,90 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' return getDisplayOffset(lyrics, data.selectedOffsetMs, indexToUse, data.local); }, [data, indexToUse, lyrics]); + const layers = useMemo(() => { + if (!Array.isArray(data?.local)) { + return null; + } + + return getLyricsLayers(data.local); + }, [data]); + + const translationLyricsOverlay = useMemo(() => { + if (!showTranslationLayer || !layers?.translation?.synced) { + return null; + } + + return layers.translation.lyrics; + }, [layers, showTranslationLayer]); + + const pronunciationLyricsOverlay = useMemo(() => { + if (!showPronunciationLayer || !layers?.pronunciation?.synced) { + return null; + } + + return layers.pronunciation.lyrics; + }, [layers, showPronunciationLayer]); + + const selectedAgents = useMemo(() => { + if (!lyrics || !('synced' in lyrics) || !lyrics.synced) { + return undefined; + } + + return lyrics.agents; + }, [lyrics]); + const displayOffsetMs = isLyricsDisabled ? 0 : currentOffsetMs; + const useServerPronunciation = !!pronunciationLyricsOverlay; + + const { data: syncedRomajiLyrics } = useSyncedRomajiLyrics( + rawSyncedLyrics, + !!enableRomaji && + !!rawSyncedLyrics && + lyricsHasWordCues(rawSyncedLyrics) && + !useServerPronunciation, + ); + + const isKaraoke = useMemo(() => { + if (!synced || !displayLyrics || !('lyrics' in displayLyrics)) { + return false; + } + + return Array.isArray(displayLyrics.lyrics) && lyricsHasWordCues(displayLyrics.lyrics); + }, [displayLyrics, synced]); + + const syncedLyricsProps = useMemo(() => { + if (!displayLyrics) { + return null; + } + + return { + ...(displayLyrics as SynchronizedLyricsProps), + offsetMs: displayOffsetMs, + pronunciationLyrics: pronunciationLyricsOverlay, + romajiLyrics: + enableRomaji && !useServerPronunciation + ? (romajiConvertedLyrics as SynchronizedLyricsProps['romajiLyrics']) + : null, + settingsKey, + syncedRomajiLyrics: + enableRomaji && !useServerPronunciation ? (syncedRomajiLyrics ?? null) : null, + translatedLyrics: showTranslation && !showTranslationLayer ? translatedLyrics : null, + translationLyrics: translationLyricsOverlay, + }; + }, [ + displayLyrics, + displayOffsetMs, + enableRomaji, + pronunciationLyricsOverlay, + romajiConvertedLyrics, + syncedRomajiLyrics, + settingsKey, + showTranslation, + showTranslationLayer, + translatedLyrics, + translationLyricsOverlay, + useServerPronunciation, + ]); const handleOnSearchOverride = useCallback( (params: LyricsOverride) => { @@ -219,7 +320,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' const fetchTranslation = useCallback(async () => { if (!lyrics || isLyricsDisabled) return; const originalLyrics = Array.isArray(lyrics.lyrics) - ? lyrics.lyrics.map(([, line]) => line).join('\n') + ? lyrics.lyrics.map((line) => getLyricLineText(line)).join('\n') : lyrics.lyrics; const TranslatedText: null | string = await translateLyrics( originalLyrics, @@ -250,6 +351,8 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' onCurrentSongChange: () => { setIndexState(0); setShowTranslation(false); + setShowTranslationLayer(false); + setShowPronunciationLayer(false); setTranslatedLyrics(null); }, }, @@ -265,7 +368,10 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' const languages = useMemo(() => { const local = data?.local; if (Array.isArray(local)) { - return local.map((lyric, idx) => ({ label: lyric.lang, value: idx.toString() })); + return local.map((lyric, idx) => ({ + label: formatStructuredLyricLabel(lyric), + value: idx.toString(), + })); } if (local && !Array.isArray(local) && 'lyrics' in local) { return [{ label: 'xxx', value: '0' }]; @@ -345,18 +451,15 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' initial={{ opacity: 0 }} transition={{ duration: 0.5 }} > - {synced ? ( - + {synced && syncedLyricsProps ? ( + isKaraoke ? ( + + ) : ( + + ) ) : ( + setShowPronunciationLayer((current) => !current) + } + onToggleTranslationLayer={() => + setShowTranslationLayer((current) => !current) + } onTranslateLyric={ translationApiProvider && translationApiKey ? handleOnTranslateLyric @@ -390,6 +501,8 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default' onUpdateOffset={handleUpdateOffset} setIndex={setIndex} settingsKey={settingsKey} + showPronunciationLayer={showPronunciationLayer} + showTranslationLayer={showTranslationLayer} /> diff --git a/src/renderer/features/lyrics/styles/karaoke-animation.css b/src/renderer/features/lyrics/styles/karaoke-animation.css new file mode 100644 index 000000000..574398563 --- /dev/null +++ b/src/renderer/features/lyrics/styles/karaoke-animation.css @@ -0,0 +1,355 @@ +/* Register animatable custom properties for gradient swipe */ +@property --lyric-transition-amount-start { + syntax: ''; + inherits: false; + initial-value: 0; +} + +@property --lyric-transition-amount-end { + syntax: ''; + inherits: false; + initial-value: 0; +} + +/* Container-level line context blur/fade (karaoke + line sync) */ +.synchronized-karaoke-lyrics:has(.lyrics-line-active):not(.lyrics-user-scrolling) + > :is(.karaoke-line, .lyric-line):not( + .lyrics-line-active, + .lyrics-line-active ~ :is(.karaoke-line, .lyric-line) + ), +.synchronized-lyrics:has(.lyrics-line-active):not(.lyrics-user-scrolling) + > .lyric-line:not(.lyrics-line-active, .lyrics-line-active ~ .lyric-line) { + opacity: 0.33; + filter: blur(2.5px); +} + +.synchronized-karaoke-lyrics > :is(.karaoke-line, .lyric-line).lyrics-line-active, +.synchronized-lyrics > .lyric-line.lyrics-line-active { + opacity: 1 !important; + filter: blur(0); +} + +.synchronized-karaoke-lyrics:has(.lyrics-line-active) + > :is(.karaoke-line, .lyric-line).lyrics-line-active + ~ :is(.karaoke-line, .lyric-line), +.synchronized-lyrics:has(.lyrics-line-active) > .lyric-line.lyrics-line-active ~ .lyric-line { + opacity: 0.66; + filter: blur(0); +} + +.synchronized-karaoke-lyrics > :is(.karaoke-line, .lyric-line).lyrics-line-animating, +.synchronized-lyrics > .lyric-line.lyrics-line-animating { + opacity: 1 !important; + filter: blur(0) !important; +} + +.synchronized-karaoke-lyrics > :is(.karaoke-line, .lyric-line):hover, +.synchronized-lyrics > .lyric-line:hover { + opacity: 1 !important; + filter: blur(0) !important; +} + +/* Word highlight via ::after pseudo-element gradient swipe */ +.karaoke-word { + position: relative; + display: inline-block; + max-width: 100%; + color: rgb(from var(--theme-colors-foreground) r g b / var(--lyric-opacity, 0.3)); + overflow-wrap: break-word; + white-space: pre-wrap; + transform: translateY(0); +} + +.karaoke-word.sung { + color: var(--theme-colors-foreground); +} + +.karaoke-line.lyrics-line-pre-animating, +.karaoke-line.lyrics-line-pre-animating .karaoke-word { + will-change: transform; +} + +.karaoke-word.lyrics-word-animating { + animation: karaoke-wobble var(--karaoke-wobble-duration, 1s) forwards ease; + animation-delay: var(--karaoke-anim-delay, 0s); + animation-play-state: running; +} + +.karaoke-word.lyrics-word-animating.lyrics-word-paused { + animation-play-state: paused; +} + +.karaoke-word::after { + position: absolute; + top: -2rem; + left: -2rem; + box-sizing: content-box; + width: 100%; + padding: 2rem; + color: transparent; + white-space: pre-wrap; + pointer-events: none; + content: attr(data-content); + background-image: linear-gradient( + 90deg, + var(--theme-colors-foreground) + calc( + 100% * var(--lyric-transition-amount-start) - 4rem * + var(--lyric-transition-amount-start) + 2rem + ), + #0000 + calc( + 100% * var(--lyric-transition-amount-end) - 4rem * + var(--lyric-transition-amount-end) + 2rem + 1px + ) + ); + background-clip: text; + opacity: 0; + transition: + --lyric-transition-amount-start 1s linear 1000s, + --lyric-transition-amount-end 1s linear 1000s, + opacity 0.5s ease; + + --lyric-transition-amount-start: -0.2; + --lyric-transition-amount-end: -0.1; + --karaoke-glow-color: color(display-p3 1 1 1 / 0.01%); +} + +.karaoke-word[data-long-word]::after { + --karaoke-glow-color: color(display-p3 1 1 1 / 100%); +} + +.karaoke-word.lyrics-word-pre-animating:not(.lyrics-word-animating)::after { + opacity: 0; + transition: none; + + --lyric-transition-amount-start: -0.2; + --lyric-transition-amount-end: -0.1; +} + +.karaoke-word.lyrics-word-pre-animating:not(.lyrics-word-animating, .karaoke-zero-dur)::after { + opacity: 1; +} + +.karaoke-word.lyrics-word-animating::after { + opacity: 1; + transition-delay: + var(--karaoke-swipe-delay, 0s), var(--karaoke-swipe-delay, 0s), + var(--karaoke-anim-delay, 0s); + transition-timing-function: linear, linear, ease; + transition-duration: + calc(var(--karaoke-duration, 1ms) * 1.6), calc(var(--karaoke-duration, 1ms) * 1.6), + var(--karaoke-highlight-fade-in-duration, 0.33s); + transition-property: --lyric-transition-amount-start, --lyric-transition-amount-end, opacity; + animation: karaoke-glow calc(var(--karaoke-duration, 1ms) * 1.6) forwards ease-out; + animation-delay: var(--karaoke-anim-delay, 0s); + animation-play-state: running; + will-change: --lyric-transition-amount-start, --lyric-transition-amount-end, opacity; + + --lyric-transition-amount-start: 1.4; + --lyric-transition-amount-end: 1.5; +} + +.karaoke-word.lyrics-word-animating.lyrics-word-paused::after { + opacity: 0.5; + transition-duration: 100000000s, 100000000s, 100000000s; + animation-play-state: paused; + + --lyric-transition-amount-start: 10; + --lyric-transition-amount-end: 10; +} + +.karaoke-word.karaoke-rtl::after { + background-image: linear-gradient( + 270deg, + var(--theme-colors-foreground) + calc( + 100% * var(--lyric-transition-amount-start) - 4rem * + var(--lyric-transition-amount-start) + 2rem + ), + #0000 + calc( + 100% * var(--lyric-transition-amount-end) - 4rem * + var(--lyric-transition-amount-end) + 2rem + 1px + ) + ); +} + +.karaoke-word.karaoke-bg-vocal { + font-size: 0.675em; + font-weight: 400; +} + +.karaoke-word.karaoke-romaji-word { + font-size: inherit; + font-weight: 600; +} + +.karaoke-word.karaoke-romaji-word::after { + top: -1.25em; + left: -1.25em; + padding: 1.25em; + background-image: linear-gradient( + 90deg, + var(--theme-colors-foreground) + calc( + 100% * var(--lyric-transition-amount-start) - 2.5em * + var(--lyric-transition-amount-start) + 1.25em + ), + #0000 + calc( + 100% * var(--lyric-transition-amount-end) - 2.5em * + var(--lyric-transition-amount-end) + 1.25em + 1px + ) + ); +} + +.karaoke-word.karaoke-romaji-word.karaoke-rtl::after { + background-image: linear-gradient( + 270deg, + var(--theme-colors-foreground) + calc( + 100% * var(--lyric-transition-amount-start) - 2.5em * + var(--lyric-transition-amount-start) + 1.25em + ), + #0000 + calc( + 100% * var(--lyric-transition-amount-end) - 2.5em * + var(--lyric-transition-amount-end) + 1.25em + 1px + ) + ); +} + +/* Furigana words use an HTML highlight twin instead of ::after */ +.karaoke-word:has(.karaoke-word-highlight) { + display: inline-block; +} + +.karaoke-word:has(.karaoke-word-highlight)::after { + display: none; + content: none; + opacity: 0; +} + +.karaoke-word-text { + display: inline; +} + +.karaoke-word-highlight { + position: absolute; + inset: 0; + box-sizing: border-box; + width: 100%; + padding: 0; + color: transparent; + overflow-wrap: break-word; + white-space: pre-wrap; + pointer-events: none; + background-image: linear-gradient( + 90deg, + var(--theme-colors-foreground) calc(100% * var(--lyric-transition-amount-start)), + #0000 calc(100% * var(--lyric-transition-amount-end) + 1px) + ); + background-clip: text; + opacity: 0; + transition: + --lyric-transition-amount-start 1s linear 1000s, + --lyric-transition-amount-end 1s linear 1000s, + opacity 0.5s ease; + + --lyric-transition-amount-start: -0.2; + --lyric-transition-amount-end: -0.1; + --karaoke-glow-color: color(display-p3 1 1 1 / 0.01%); +} + +.karaoke-word[data-long-word] .karaoke-word-highlight { + --karaoke-glow-color: color(display-p3 1 1 1 / 100%); +} + +.karaoke-word.lyrics-word-pre-animating:not(.lyrics-word-animating) .karaoke-word-highlight { + opacity: 0; + transition: none; + + --lyric-transition-amount-start: -0.2; + --lyric-transition-amount-end: -0.1; +} + +.karaoke-word.lyrics-word-pre-animating:not(.lyrics-word-animating, .karaoke-zero-dur) + .karaoke-word-highlight { + opacity: 1; +} + +.karaoke-word.lyrics-word-animating .karaoke-word-highlight { + opacity: 1; + transition-delay: + var(--karaoke-swipe-delay, 0s), var(--karaoke-swipe-delay, 0s), + var(--karaoke-anim-delay, 0s); + transition-timing-function: linear, linear, ease; + transition-duration: + calc(var(--karaoke-duration, 1ms) * 1.6), calc(var(--karaoke-duration, 1ms) * 1.6), + var(--karaoke-highlight-fade-in-duration, 0.33s); + transition-property: --lyric-transition-amount-start, --lyric-transition-amount-end, opacity; + animation: karaoke-glow calc(var(--karaoke-duration, 1ms) * 1.6) forwards ease-out; + animation-delay: var(--karaoke-anim-delay, 0s); + animation-play-state: running; + will-change: --lyric-transition-amount-start, --lyric-transition-amount-end, opacity; + + --lyric-transition-amount-start: 1.4; + --lyric-transition-amount-end: 1.5; +} + +.karaoke-word.lyrics-word-animating.lyrics-word-paused .karaoke-word-highlight { + opacity: 0.5; + transition-duration: 100000000s, 100000000s, 100000000s; + animation-play-state: paused; + + --lyric-transition-amount-start: 10; + --lyric-transition-amount-end: 10; +} + +.karaoke-word.karaoke-rtl .karaoke-word-highlight { + background-image: linear-gradient( + 270deg, + var(--theme-colors-foreground) calc(100% * var(--lyric-transition-amount-start)), + #0000 calc(100% * var(--lyric-transition-amount-end) + 1px) + ); +} + +.karaoke-word.sung .karaoke-word-highlight { + opacity: 0; +} + +.karaoke-word ruby { + ruby-position: over; +} + +.karaoke-word ruby rt { + font-size: 0.55em; + font-weight: 500; + line-height: 1; + user-select: none; +} + +.karaoke-word ruby rp { + display: none; +} + +@keyframes karaoke-wobble { + 0% { + transform: translateY(0); + } + + 100% { + transform: translateY(-0.0375em); + } +} + +@keyframes karaoke-glow { + 0% { + filter: drop-shadow(0 0 0.8rem var(--karaoke-glow-color)); + } + + 100% { + filter: drop-shadow(0 0 0 var(--karaoke-glow-color)); + } +} diff --git a/src/renderer/features/lyrics/synchronized-karaoke-lyrics.module.css b/src/renderer/features/lyrics/synchronized-karaoke-lyrics.module.css new file mode 100644 index 000000000..98b03e0cd --- /dev/null +++ b/src/renderer/features/lyrics/synchronized-karaoke-lyrics.module.css @@ -0,0 +1,14 @@ +.container { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + min-height: 0; + padding: 10vh 0 50vh; + overflow: hidden auto; + word-break: break-all; + + @media screen and (orientation: portrait) { + padding: 5vh 0; + } +} diff --git a/src/renderer/features/lyrics/synchronized-karaoke-lyrics.tsx b/src/renderer/features/lyrics/synchronized-karaoke-lyrics.tsx new file mode 100644 index 000000000..c17483f32 --- /dev/null +++ b/src/renderer/features/lyrics/synchronized-karaoke-lyrics.tsx @@ -0,0 +1,357 @@ +import clsx from 'clsx'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import styles from './synchronized-karaoke-lyrics.module.css'; + +import { + findOverlayLineByTime, + getLyricLineStartMs, + getLyricLineText, + normalizeLyrics, +} from '/@/renderer/features/lyrics/api/lyrics-utils'; +import { SyncedRomajiLyrics } from '/@/renderer/features/lyrics/hooks/use-furigana-lyrics'; +import { useLyricsAnimationEngine } from '/@/renderer/features/lyrics/hooks/use-lyrics-animation-engine'; +import { + LYRICS_SCROLL_CONTAINER_ID, + useSynchronizedLyricsBase, +} from '/@/renderer/features/lyrics/hooks/use-synchronized-lyrics-base'; +import { KaraokeLyricLine } from '/@/renderer/features/lyrics/karaoke-lyric-line'; +import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; +import { subscribePlayerStatus, usePlayerStoreBase } from '/@/renderer/store'; +import { subscribePlayerProgress, useTimestampStoreBase } from '/@/renderer/store/timestamp.store'; +import { + FullLyricsMetadata, + LyricAgent, + SynchronizedLyrics as SynchronizedLyricsData, +} from '/@/shared/types/domain-types'; +import { PlayerStatus } from '/@/shared/types/types'; + +export interface SynchronizedKaraokeLyricsProps extends Omit { + agents?: LyricAgent[]; + lyrics: SynchronizedLyricsData; + offsetMs?: number; + pronunciationLyrics?: null | SynchronizedLyricsData; + romajiLyrics?: null | SynchronizedLyricsData; + settingsKey?: string; + style?: React.CSSProperties; + syncedRomajiLyrics?: null | SyncedRomajiLyrics; + translatedLyrics?: null | string; + translationLyrics?: null | SynchronizedLyricsData; +} + +const SEEK_DETECT_THRESHOLD_MS = 500; + +export const SynchronizedKaraokeLyrics = ({ + agents, + artist, + lyrics, + name, + offsetMs, + pronunciationLyrics, + remote, + romajiLyrics, + settingsKey = 'default', + source, + style, + syncedRomajiLyrics, + translatedLyrics, + translationLyrics, +}: SynchronizedKaraokeLyricsProps) => { + const { + containerRef, + containerStyle, + delayMsRef, + followRef, + handleSeek, + hideScrollbar, + lineLeadTimeMsRef, + lyricRef, + resumeAutoscroll, + scrollAnimStateRef, + settings, + showScrollbar, + } = useSynchronizedLyricsBase(settingsKey, offsetMs); + + const normalizedLyrics = useMemo(() => normalizeLyrics(lyrics), [lyrics]); + const rafRef = useRef(null); + const statusRef = useRef(usePlayerStoreBase.getState().player.status); + const lastSyncedTimeRef = useRef(0); + const playbackAnchorRef = useRef({ + // eslint-disable-next-line react-hooks/purity + eventCreationTime: Date.now(), + timeMs: useTimestampStoreBase.getState().timestamp * 1000, + }); + + const { + rebuildLyricsData, + reset, + resumeAutoscroll: resumeEngineAutoscroll, + tick, + } = useLyricsAnimationEngine({ + animStateRef: scrollAnimStateRef, + containerRef, + followRef, + lineIdPrefix: 'karaoke-line', + lineLeadTimeMsRef, + lyrics: normalizedLyrics, + scrollContainerId: LYRICS_SCROLL_CONTAINER_ID, + }); + + const handleContainerClick = useCallback( + (event: React.MouseEvent) => { + const wordTarget = (event.target as HTMLElement).closest('[data-word-start]'); + if (wordTarget) { + const wordStart = Number((wordTarget as HTMLElement).dataset.wordStart); + if (Number.isFinite(wordStart)) { + resumeAutoscroll(); + resumeEngineAutoscroll(); + handleSeek(wordStart / 1000); + } + return; + } + + const target = (event.target as HTMLElement).closest('[data-lyric-time]'); + if (!target) { + return; + } + + const time = Number((target as HTMLElement).dataset.lyricTime); + if (time >= 0 && Number.isFinite(time)) { + resumeAutoscroll(); + resumeEngineAutoscroll(); + handleSeek(time / 1000); + } + }, + [handleSeek, resumeAutoscroll, resumeEngineAutoscroll], + ); + + const syncAtTime = useCallback( + ( + timeInMs: number, + isPlaying: boolean, + options?: { eventCreationTime?: number; forceReset?: boolean; forceResync?: boolean }, + ) => { + if (options?.forceReset) { + reset(); + rebuildLyricsData(); + } + + tick(timeInMs, isPlaying, { + eventCreationTime: options?.eventCreationTime ?? Date.now(), + forceResync: options?.forceResync ?? false, + }); + lastSyncedTimeRef.current = timeInMs; + }, + [rebuildLyricsData, reset, tick], + ); + + const updatePlaybackAnchor = useCallback((timestampSec: number) => { + playbackAnchorRef.current = { + eventCreationTime: Date.now(), + timeMs: timestampSec * 1000, + }; + }, []); + + const stopRaf = useCallback(() => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }, []); + + const startRaf = useCallback(() => { + stopRaf(); + + const runTick = () => { + if (statusRef.current !== PlayerStatus.PLAYING) { + stopRaf(); + return; + } + + const anchor = playbackAnchorRef.current; + const timeInMs = anchor.timeMs + delayMsRef.current; + + syncAtTime(timeInMs, true, { + eventCreationTime: anchor.eventCreationTime, + }); + + rafRef.current = requestAnimationFrame(runTick); + }; + + rafRef.current = requestAnimationFrame(runTick); + }, [delayMsRef, stopRaf, syncAtTime]); + + const syncFromCurrentTimestamp = useCallback(() => { + const timestamp = useTimestampStoreBase.getState().timestamp; + updatePlaybackAnchor(timestamp); + const isPlaying = statusRef.current === PlayerStatus.PLAYING; + const timeInMs = timestamp * 1000 + delayMsRef.current; + syncAtTime(timeInMs, isPlaying, { forceReset: true, forceResync: true }); + }, [delayMsRef, syncAtTime, updatePlaybackAnchor]); + + useEffect(() => { + lyricRef.current = normalizedLyrics; + lastSyncedTimeRef.current = 0; + + const frame = requestAnimationFrame(() => { + rebuildLyricsData(); + + if (statusRef.current === PlayerStatus.PLAYING) { + startRaf(); + } else { + syncFromCurrentTimestamp(); + } + }); + + return () => { + cancelAnimationFrame(frame); + stopRaf(); + reset(); + }; + }, [ + lyricRef, + normalizedLyrics, + rebuildLyricsData, + reset, + startRaf, + stopRaf, + syncFromCurrentTimestamp, + ]); + + useEffect(() => { + syncFromCurrentTimestamp(); + }, [offsetMs, syncFromCurrentTimestamp]); + + useEffect(() => { + statusRef.current = usePlayerStoreBase.getState().player.status; + + const unsubscribe = subscribePlayerStatus(({ status }) => { + statusRef.current = status; + + if (status !== PlayerStatus.PLAYING) { + stopRaf(); + syncFromCurrentTimestamp(); + return; + } + + startRaf(); + }); + + return unsubscribe; + }, [startRaf, stopRaf, syncFromCurrentTimestamp]); + + useEffect(() => { + const unsubscribe = subscribePlayerProgress(({ timestamp }) => { + const isPlaying = statusRef.current === PlayerStatus.PLAYING; + const timeInMs = timestamp * 1000 + delayMsRef.current; + const previousTimeMs = lastSyncedTimeRef.current; + const isSeek = + previousTimeMs > 0 && + Math.abs(timeInMs - previousTimeMs) > SEEK_DETECT_THRESHOLD_MS; + + updatePlaybackAnchor(timestamp); + + if (!isPlaying) { + syncAtTime(timeInMs, false, { forceReset: true, forceResync: true }); + return; + } + + if (isSeek) { + syncAtTime(timeInMs, true, { + eventCreationTime: playbackAnchorRef.current.eventCreationTime, + forceReset: true, + forceResync: true, + }); + } + }); + + return unsubscribe; + }, [delayMsRef, syncAtTime, updatePlaybackAnchor]); + + const getOverlayText = ( + overlayLyrics: null | SynchronizedLyricsData | undefined, + startMs: number, + fallback?: null | string, + ) => { + if (overlayLyrics) { + return findOverlayLineByTime(overlayLyrics, startMs); + } + + return fallback; + }; + + return ( +
+ {settings.showProvider && source && ( + + )} + {settings.showMatch && remote && ( + + )} + {normalizedLyrics.map((rawLine, idx) => { + const lineStartMs = getLyricLineStartMs(rawLine); + const pronunciationText = getOverlayText( + pronunciationLyrics, + lineStartMs, + romajiLyrics?.[idx] ? getLyricLineText(romajiLyrics[idx]) : undefined, + ); + const translationText = getOverlayText( + translationLyrics, + lineStartMs, + translatedLyrics?.split('\n')[idx], + ); + + if (!rawLine.cueLines?.length) { + return ( + + ); + } + + return ( + + ); + })} +
+ ); +}; diff --git a/src/renderer/features/lyrics/synchronized-lyrics.module.css b/src/renderer/features/lyrics/synchronized-lyrics.module.css index 1cda05bf2..f7b5a8e37 100644 --- a/src/renderer/features/lyrics/synchronized-lyrics.module.css +++ b/src/renderer/features/lyrics/synchronized-lyrics.module.css @@ -1,10 +1,11 @@ .container { display: flex; + flex: 1; flex-direction: column; width: 100%; - height: 100%; + min-height: 0; padding: 10vh 0 50vh; - overflow: scroll; + overflow: hidden auto; word-break: break-all; transform: translateY(-2rem); diff --git a/src/renderer/features/lyrics/synchronized-lyrics.tsx b/src/renderer/features/lyrics/synchronized-lyrics.tsx index c8cbba29d..24165a2d6 100644 --- a/src/renderer/features/lyrics/synchronized-lyrics.tsx +++ b/src/renderer/features/lyrics/synchronized-lyrics.tsx @@ -1,273 +1,172 @@ import clsx from 'clsx'; -import isElectron from 'is-electron'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import styles from './synchronized-lyrics.module.css'; -import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; import { - subscribePlayerStatus, - useLyricsDisplaySettings, - useLyricsSettings, - usePlaybackType, - usePlayerActions, - usePlayerStoreBase, -} from '/@/renderer/store'; + findOverlayLineByTime, + getLyricLineStartMs, + getLyricLineText, + normalizeLyrics, +} from '/@/renderer/features/lyrics/api/lyrics-utils'; +import { useLyricsAnimationEngine } from '/@/renderer/features/lyrics/hooks/use-lyrics-animation-engine'; +import { + LYRICS_SCROLL_CONTAINER_ID, + useSynchronizedLyricsBase, +} from '/@/renderer/features/lyrics/hooks/use-synchronized-lyrics-base'; +import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; +import { subscribePlayerStatus, usePlayerStoreBase } from '/@/renderer/store'; import { subscribePlayerProgress, useTimestampStoreBase } from '/@/renderer/store/timestamp.store'; -import { FullLyricsMetadata, SynchronizedLyricsArray } from '/@/shared/types/domain-types'; -import { PlayerStatus, PlayerType } from '/@/shared/types/types'; - -const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; -const utils = isElectron() ? window.api.utils : null; -const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null; +import { + FullLyricsMetadata, + SynchronizedLyrics as SynchronizedLyricsData, +} from '/@/shared/types/domain-types'; +import { PlayerStatus } from '/@/shared/types/types'; export interface SynchronizedLyricsProps extends Omit { - lyrics: SynchronizedLyricsArray; + lyrics: SynchronizedLyricsData; offsetMs?: number; - romajiLyrics?: null | SynchronizedLyricsArray; + pronunciationLyrics?: null | SynchronizedLyricsData; + romajiLyrics?: null | SynchronizedLyricsData; settingsKey?: string; style?: React.CSSProperties; translatedLyrics?: null | string; + translationLyrics?: null | SynchronizedLyricsData; } +const SEEK_DETECT_THRESHOLD_MS = 500; + export const SynchronizedLyrics = ({ artist, lyrics, name, offsetMs, + pronunciationLyrics, remote, romajiLyrics, settingsKey = 'default', source, style, translatedLyrics, + translationLyrics, }: SynchronizedLyricsProps) => { - const playbackType = usePlaybackType(); - const lyricsSettings = useLyricsSettings(); - const displaySettings = useLyricsDisplaySettings(settingsKey); - const settings = { - ...lyricsSettings, - fontSize: - displaySettings.fontSize && displaySettings.fontSize !== 0 - ? displaySettings.fontSize - : 24, - gap: displaySettings.gap && displaySettings.gap !== 0 ? displaySettings.gap : 24, - opacityNonActive: displaySettings.opacityNonActive, - scaleNonActive: - displaySettings.scaleNonActive && displaySettings.scaleNonActive !== 0 - ? displaySettings.scaleNonActive - : 0.95, - }; - const { mediaSeekToTimestamp } = usePlayerActions(); + const { + containerRef, + containerStyle, + delayMsRef, + followRef, + handleLineClick, + hideScrollbar, + lineLeadTimeMsRef, + lyricRef, + resumeAutoscroll, + scrollAnimStateRef, + settings, + showScrollbar, + } = useSynchronizedLyricsBase(settingsKey, offsetMs); - const effectiveOffsetMs = offsetMs ?? 0; - - const handleSeek = useCallback( - (time: number) => { - if (playbackType === PlayerType.LOCAL && mpvPlayer) { - mpvPlayer.seekTo(time); - } else { - mpris?.updateSeek(time); - mediaSeekToTimestamp(time); - } - }, - [mediaSeekToTimestamp, playbackType], - ); - - const handleContainerClick = useCallback( - (event: React.MouseEvent) => { - const target = (event.target as HTMLElement).closest('[data-lyric-time]'); - if (!target) { - return; - } - - const time = Number((target as HTMLElement).dataset.lyricTime); - if (time > 0 && Number.isFinite(time)) { - handleSeek(time / 1000); - } - }, - [handleSeek], - ); - - // A reference to the timeout handler - const lyricTimer = useRef>(null); - - // A reference to the lyrics. This is necessary for the - // timers, which are not part of react necessarily, to always - // have the most updated values - const lyricRef = useRef(null); - - // A constantly increasing value, used to tell timers that may be out of date - // whether to proceed or stop - const timerEpoch = useRef(0); - - const delayMsRef = useRef(effectiveOffsetMs); - const followRef = useRef(settings.follow); + const normalizedLyrics = useMemo(() => normalizeLyrics(lyrics), [lyrics]); + const rafRef = useRef(null); const statusRef = useRef(usePlayerStoreBase.getState().player.status); - const userScrollingRef = useRef(false); - const scrollTimeoutRef = useRef>(null); - const containerRef = useRef(null); - const programmaticScrollRef = useRef(false); - const programmaticScrollTimeoutRef = useRef>(null); + const lastSyncedTimeRef = useRef(0); - const getCurrentLyric = (timeInMs: number) => { - const activeLyrics = lyricRef.current; - if (!activeLyrics?.length) { - return -1; - } + const { + rebuildLyricsData, + reset, + resumeAutoscroll: resumeEngineAutoscroll, + tick, + } = useLyricsAnimationEngine({ + animStateRef: scrollAnimStateRef, + containerRef, + followRef, + lineIdPrefix: 'lyric', + lineLeadTimeMsRef, + lyrics: normalizedLyrics, + scrollContainerId: LYRICS_SCROLL_CONTAINER_ID, + }); - let index = -1; - for (let idx = 0; idx < activeLyrics.length; idx += 1) { - if (timeInMs < activeLyrics[idx][0]) { - break; - } - index = idx; - } - - return index; - }; - - const setCurrentLyricRef = useRef< - (timeInMs: number, epoch?: number, targetIndex?: number) => void - >(() => {}); - - const setCurrentLyric = useCallback( - (timeInMs: number, epoch?: number, targetIndex?: number) => { - const start = performance.now(); - let nextEpoch: number; - - if (epoch === undefined) { - timerEpoch.current = (timerEpoch.current + 1) % 10000; - nextEpoch = timerEpoch.current; - } else if (epoch !== timerEpoch.current) { - return; - } else { - nextEpoch = epoch; + const syncAtTime = useCallback( + (timeInMs: number, isPlaying: boolean, forceReset = false) => { + if (forceReset) { + reset(); + rebuildLyricsData(); } - let index: number; - - if (targetIndex === undefined) { - index = getCurrentLyric(timeInMs); - } else { - index = targetIndex; - } - - // Directly modify the dom instead of using react to prevent rerender - document - .querySelectorAll('.synchronized-lyrics .active') - .forEach((node) => node.classList.remove('active')); - - if (index === -1) { - const activeLyrics = lyricRef.current; - if (!activeLyrics?.length) { - return; - } - - const firstTime = activeLyrics[0][0]; - if (timeInMs < firstTime) { - const elapsed = performance.now() - start; - const delay = Math.max(0, firstTime - timeInMs - elapsed); - lyricTimer.current = setTimeout(() => { - setCurrentLyricRef.current(firstTime, nextEpoch, 0); - }, delay); - } - - return; - } - - const doc = document.getElementById( - 'sychronized-lyrics-scroll-container', - ) as HTMLElement; - const currentLyric = document.querySelector(`#lyric-${index}`) as HTMLElement; - - const offsetTop = currentLyric?.offsetTop - doc?.clientHeight / 2 || 0; - - if (currentLyric === null) { - return; - } - - currentLyric.classList.add('active'); - - if (followRef.current && !userScrollingRef.current) { - programmaticScrollRef.current = true; - doc?.scroll({ behavior: 'smooth', top: offsetTop }); - } - - if (index !== lyricRef.current!.length - 1) { - const nextTime = lyricRef.current![index + 1][0]; - - const elapsed = performance.now() - start; - - lyricTimer.current = setTimeout( - () => { - setCurrentLyricRef.current(nextTime, nextEpoch, index + 1); - }, - nextTime - timeInMs - elapsed, - ); - } + tick(timeInMs, isPlaying); + lastSyncedTimeRef.current = timeInMs; }, - [], + [rebuildLyricsData, reset, tick], ); + const stopRaf = useCallback(() => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }, []); + + const startRaf = useCallback(() => { + stopRaf(); + + const runTick = () => { + if (statusRef.current !== PlayerStatus.PLAYING) { + stopRaf(); + return; + } + + const timestamp = useTimestampStoreBase.getState().timestamp; + const timeInMs = timestamp * 1000 + delayMsRef.current; + + if (Math.abs(timeInMs - lastSyncedTimeRef.current) > SEEK_DETECT_THRESHOLD_MS) { + syncAtTime(timeInMs, true, true); + } else { + syncAtTime(timeInMs, true); + } + + rafRef.current = requestAnimationFrame(runTick); + }; + + rafRef.current = requestAnimationFrame(runTick); + }, [delayMsRef, stopRaf, syncAtTime]); + const syncFromCurrentTimestamp = useCallback(() => { const timestamp = useTimestampStoreBase.getState().timestamp; - setCurrentLyric(timestamp * 1000 + delayMsRef.current); - }, [setCurrentLyric]); - - // Store the callback in a ref so it can be called recursively - useEffect(() => { - setCurrentLyricRef.current = setCurrentLyric; - }, [setCurrentLyric]); + const isPlaying = statusRef.current === PlayerStatus.PLAYING; + syncAtTime(timestamp * 1000 + delayMsRef.current, isPlaying, true); + }, [delayMsRef, syncAtTime]); useEffect(() => { - // Copy the follow settings into a ref that can be accessed in the timeout - followRef.current = settings.follow; - }, [settings.follow]); + lyricRef.current = normalizedLyrics; + lastSyncedTimeRef.current = 0; - useEffect(() => { - lyricRef.current = lyrics; + const frame = requestAnimationFrame(() => { + rebuildLyricsData(); - if (statusRef.current === PlayerStatus.PLAYING) { - syncFromCurrentTimestamp(); - } - - return () => { - if (lyricTimer.current) { - clearTimeout(lyricTimer.current); + if (statusRef.current === PlayerStatus.PLAYING) { + startRaf(); + } else { + syncFromCurrentTimestamp(); } - }; - }, [lyrics, syncFromCurrentTimestamp]); - - useEffect(() => { - const newOffset = offsetMs ?? 0; - if (delayMsRef.current === newOffset) { - return; - } - - if (lyricTimer.current) { - clearTimeout(lyricTimer.current); - } - - delayMsRef.current = newOffset; - syncFromCurrentTimestamp(); - }, [offsetMs, syncFromCurrentTimestamp]); - - useEffect(() => { - const unsubscribe = subscribePlayerProgress(({ timestamp }) => { - if (statusRef.current !== PlayerStatus.PLAYING) { - return; - } - - if (lyricTimer.current) { - clearTimeout(lyricTimer.current); - } - - setCurrentLyric(timestamp * 1000 + delayMsRef.current); }); - return unsubscribe; - }, [setCurrentLyric]); + return () => { + cancelAnimationFrame(frame); + stopRaf(); + reset(); + }; + }, [ + lyricRef, + normalizedLyrics, + rebuildLyricsData, + reset, + startRaf, + stopRaf, + syncFromCurrentTimestamp, + ]); + + useEffect(() => { + syncFromCurrentTimestamp(); + }, [offsetMs, syncFromCurrentTimestamp]); useEffect(() => { statusRef.current = usePlayerStoreBase.getState().player.status; @@ -276,110 +175,65 @@ export const SynchronizedLyrics = ({ statusRef.current = status; if (status !== PlayerStatus.PLAYING) { - if (lyricTimer.current) { - clearTimeout(lyricTimer.current); - } - + stopRaf(); + syncFromCurrentTimestamp(); return; } - if (lyricTimer.current) { - clearTimeout(lyricTimer.current); - } - - syncFromCurrentTimestamp(); + startRaf(); }); return unsubscribe; - }, [syncFromCurrentTimestamp]); + }, [startRaf, stopRaf, syncFromCurrentTimestamp]); useEffect(() => { - // Guaranteed cleanup; stop the timer, and just in case also increment - // the epoch to instruct any dangling timers to stop - if (lyricTimer.current) { - clearTimeout(lyricTimer.current); - } - - timerEpoch.current += 1; - }, []); - - // Handle manual scrolling - pause auto-scroll when user scrolls - useEffect(() => { - const container = - containerRef.current || - (document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement); - if (!container) return; - - const handleScroll = () => { - // Ignore programmatic scrolls (auto-scroll) - if (programmaticScrollRef.current) { - if (programmaticScrollTimeoutRef.current) { - clearTimeout(programmaticScrollTimeoutRef.current); - } - - programmaticScrollTimeoutRef.current = setTimeout(() => { - programmaticScrollRef.current = false; - }, 150); + const unsubscribe = subscribePlayerProgress(({ timestamp }) => { + const timeInMs = timestamp * 1000 + delayMsRef.current; + const isPlaying = statusRef.current === PlayerStatus.PLAYING; + if (!isPlaying) { + syncAtTime(timeInMs, false, true); return; } - userScrollingRef.current = true; - - if (scrollTimeoutRef.current) { - clearTimeout(scrollTimeoutRef.current); + if (Math.abs(timeInMs - lastSyncedTimeRef.current) > SEEK_DETECT_THRESHOLD_MS) { + syncAtTime(timeInMs, true, true); } + }); - // Re-enable auto-scroll after 3 seconds of no scrolling - scrollTimeoutRef.current = setTimeout(() => { - userScrollingRef.current = false; - }, 3000); - }; + return unsubscribe; + }, [delayMsRef, syncAtTime]); - container.addEventListener('scroll', handleScroll, { passive: true }); + const handleContainerClick = useCallback( + (event: React.MouseEvent) => { + resumeAutoscroll(); + resumeEngineAutoscroll(); + handleLineClick(event); + }, + [handleLineClick, resumeAutoscroll, resumeEngineAutoscroll], + ); - return () => { - container.removeEventListener('scroll', handleScroll); - if (scrollTimeoutRef.current) { - clearTimeout(scrollTimeoutRef.current); - } + const getOverlayText = ( + overlayLyrics: null | SynchronizedLyricsData | undefined, + startMs: number, + fallback?: null | string, + ) => { + if (overlayLyrics) { + return findOverlayLineByTime(overlayLyrics, startMs); + } - if (programmaticScrollTimeoutRef.current) { - clearTimeout(programmaticScrollTimeoutRef.current); - } - }; - }, []); - - const hideScrollbar = () => { - const doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement; - doc.classList.add('hide-scrollbar'); - }; - - const showScrollbar = () => { - const doc = document.getElementById('sychronized-lyrics-scroll-container') as HTMLElement; - doc.classList.remove('hide-scrollbar'); + return fallback; }; return (
{settings.showProvider && source && ( )} - {lyrics.map(([time, text], idx) => ( - - ))} + {normalizedLyrics.map((rawLine, idx) => { + const lineStartMs = getLyricLineStartMs(rawLine); + const lineText = getLyricLineText(rawLine); + const pronunciationText = getOverlayText( + pronunciationLyrics, + lineStartMs, + romajiLyrics?.[idx] ? getLyricLineText(romajiLyrics[idx]) : undefined, + ); + const translationText = getOverlayText( + translationLyrics, + lineStartMs, + translatedLyrics?.split('\n')[idx], + ); + + return ( + + ); + })}
); }; diff --git a/src/renderer/features/lyrics/unsynchronized-lyrics.module.css b/src/renderer/features/lyrics/unsynchronized-lyrics.module.css index a4887fc9a..f207f1adf 100644 --- a/src/renderer/features/lyrics/unsynchronized-lyrics.module.css +++ b/src/renderer/features/lyrics/unsynchronized-lyrics.module.css @@ -1,10 +1,11 @@ .container { display: flex; + flex: 1; flex-direction: column; width: 100%; - height: 100%; + min-height: 0; padding: 10vh 0 6vh; - overflow: scroll; + overflow: hidden auto; transform: translateY(-2rem); @media screen and (orientation: portrait) { diff --git a/src/renderer/features/now-playing/components/sidebar-play-queue.module.css b/src/renderer/features/now-playing/components/sidebar-play-queue.module.css index 59d37d1b9..8e5bc463e 100644 --- a/src/renderer/features/now-playing/components/sidebar-play-queue.module.css +++ b/src/renderer/features/now-playing/components/sidebar-play-queue.module.css @@ -20,7 +20,8 @@ background-color: var(--theme-colors-background-alternate); } -.lyrics-section :global(.synchronized-lyrics) { +.lyrics-section :global(.synchronized-lyrics), +.lyrics-section :global(.synchronized-karaoke-lyrics) { padding: 2rem 0 4rem !important; transform: translateY(0) !important; } diff --git a/src/renderer/features/player/audio-player/mpv-player.tsx b/src/renderer/features/player/audio-player/mpv-player.tsx index ebf469b72..9da52b473 100644 --- a/src/renderer/features/player/audio-player/mpv-player.tsx +++ b/src/renderer/features/player/audio-player/mpv-player.tsx @@ -162,7 +162,7 @@ export function MpvPlayer() { try { const time = await mpvPlayer.getCurrentTime(); if (time !== undefined) { - setTimestamp(Number(time.toFixed(0))); + setTimestamp(time); } } catch { // Do nothing diff --git a/src/renderer/features/player/audio-player/wavesurfer-player.tsx b/src/renderer/features/player/audio-player/wavesurfer-player.tsx index 5d56a3e6d..fe0040de4 100644 --- a/src/renderer/features/player/audio-player/wavesurfer-player.tsx +++ b/src/renderer/features/player/audio-player/wavesurfer-player.tsx @@ -77,6 +77,10 @@ export function WaveSurferPlayer() { return; } + if (num === 1) { + setTimestamp(e.playedSeconds); + } + switch (transitionType) { case PlayerStyle.CROSSFADE: crossfadeHandler({ @@ -105,7 +109,7 @@ export function WaveSurferPlayer() { break; } }, - [crossfadeDuration, isTransitioning, num, player2, transitionType, volume], + [crossfadeDuration, isTransitioning, num, player2, setTimestamp, transitionType, volume], ); const onProgressPlayer2 = useCallback( @@ -114,6 +118,10 @@ export function WaveSurferPlayer() { return; } + if (num === 2) { + setTimestamp(e.playedSeconds); + } + switch (transitionType) { case PlayerStyle.CROSSFADE: crossfadeHandler({ @@ -142,7 +150,7 @@ export function WaveSurferPlayer() { break; } }, - [crossfadeDuration, isTransitioning, num, player1, transitionType, volume], + [crossfadeDuration, isTransitioning, num, player1, setTimestamp, transitionType, volume], ); const handleOnEndedPlayer1 = useCallback(() => { @@ -224,7 +232,7 @@ export function WaveSurferPlayer() { transitionType === PlayerStyle.CROSSFADE || transitionType === PlayerStyle.GAPLESS ) { - setTimestamp(Number(currentTime.toFixed(0))); + setTimestamp(currentTime); } }, 500); diff --git a/src/renderer/features/player/audio-player/web-player.tsx b/src/renderer/features/player/audio-player/web-player.tsx index ead7bd114..66da5c38b 100644 --- a/src/renderer/features/player/audio-player/web-player.tsx +++ b/src/renderer/features/player/audio-player/web-player.tsx @@ -127,6 +127,10 @@ export function WebPlayer() { return; } + if (num === 1) { + setTimestamp(e.playedSeconds); + } + if (repeat === PlayerRepeat.ONE) { handleRepeatOne(1, e.playedSeconds, getDuration(playerRef.current.player1().ref)); return; @@ -170,6 +174,7 @@ export function WebPlayer() { num, player2, repeat, + setTimestamp, transitionType, volume, ], @@ -181,6 +186,10 @@ export function WebPlayer() { return; } + if (num === 2) { + setTimestamp(e.playedSeconds); + } + if (repeat === PlayerRepeat.ONE) { handleRepeatOne(2, e.playedSeconds, getDuration(playerRef.current.player2().ref)); return; @@ -224,6 +233,7 @@ export function WebPlayer() { num, player1, repeat, + setTimestamp, transitionType, volume, ], @@ -388,7 +398,7 @@ export function WebPlayer() { transitionType === PlayerStyle.CROSSFADE || transitionType === PlayerStyle.GAPLESS ) { - setTimestamp(Number(currentTime.toFixed(0))); + setTimestamp(currentTime); } }, 500); diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 1e69d8c75..75c08d54d 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -68,6 +68,7 @@ declare global { FS_LYRICS_ENABLE_AUTO_TRANSLATION?: string; FS_LYRICS_FETCH?: string; FS_LYRICS_FOLLOW?: string; + FS_LYRICS_LINE_LEAD_TIME_MS?: string; FS_LYRICS_PREFER_LOCAL?: string; FS_LYRICS_SHOW_MATCH?: string; FS_LYRICS_SHOW_PROVIDER?: string; diff --git a/src/renderer/store/env-settings-overrides.ts b/src/renderer/store/env-settings-overrides.ts index 792e0b901..e8acc1814 100644 --- a/src/renderer/store/env-settings-overrides.ts +++ b/src/renderer/store/env-settings-overrides.ts @@ -403,6 +403,7 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [ { key: 'FS_LYRICS_FETCH', path: ['lyrics', 'fetch'], type: 'bool' }, { key: 'FS_LYRICS_FOLLOW', path: ['lyrics', 'follow'], type: 'bool' }, { key: 'FS_LYRICS_DELAY_MS', path: ['lyrics', 'delayMs'], type: 'num' }, + { key: 'FS_LYRICS_LINE_LEAD_TIME_MS', path: ['lyrics', 'lineLeadTimeMs'], type: 'num' }, { key: 'FS_LYRICS_PREFER_LOCAL', path: ['lyrics', 'preferLocalLyrics'], type: 'bool' }, { key: 'FS_LYRICS_SHOW_MATCH', path: ['lyrics', 'showMatch'], type: 'bool' }, { key: 'FS_LYRICS_SHOW_PROVIDER', path: ['lyrics', 'showProvider'], type: 'bool' }, diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 37b204956..f8c212ea9 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -582,6 +582,7 @@ const LyricsSettingsSchema = z.object({ enableRomaji: z.boolean().optional(), fetch: z.boolean(), follow: z.boolean(), + lineLeadTimeMs: z.number(), preferLocalLyrics: z.boolean(), showMatch: z.boolean(), showProvider: z.boolean(), @@ -1855,6 +1856,7 @@ const initialState: SettingsState = { enableRomaji: false, fetch: true, follow: true, + lineLeadTimeMs: 800, preferLocalLyrics: true, showMatch: true, showProvider: true, diff --git a/src/renderer/utils/utf8-byte-slice.ts b/src/renderer/utils/utf8-byte-slice.ts new file mode 100644 index 000000000..d39df0708 --- /dev/null +++ b/src/renderer/utils/utf8-byte-slice.ts @@ -0,0 +1,14 @@ +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export const sliceUtf8Bytes = (value: string, byteStart: number, byteEnd: number): string => { + const bytes = textEncoder.encode(value); + const start = Math.max(0, byteStart); + const end = Math.min(bytes.length - 1, byteEnd); + + if (start > end || bytes.length === 0) { + return ''; + } + + return textDecoder.decode(bytes.slice(start, end + 1)); +}; diff --git a/src/shared/api/subsonic/subsonic-types.ts b/src/shared/api/subsonic/subsonic-types.ts index 660aa1711..29809884b 100644 --- a/src/shared/api/subsonic/subsonic-types.ts +++ b/src/shared/api/subsonic/subsonic-types.ts @@ -400,6 +400,7 @@ const serverInfo = z.object({ }); const structuredLyricsParameters = z.object({ + enhanced: z.boolean().optional(), id: z.string(), }); @@ -408,9 +409,39 @@ const lyricLine = z.object({ value: z.string(), }); +const lyricAgentRole = z.enum(['main', 'voice', 'bg', 'group']); + +const lyricAgent = z.object({ + id: z.string(), + name: z.string().optional(), + role: lyricAgentRole, +}); + +const lyricCue = z.object({ + byteEnd: z.number(), + byteStart: z.number(), + end: z.number(), + start: z.number(), + value: z.string(), +}); + +const lyricCueLine = z.object({ + agentId: z.string().optional(), + cue: z.array(lyricCue).optional(), + end: z.number(), + index: z.number(), + start: z.number(), + value: z.string(), +}); + +const structuredLyricKind = z.enum(['main', 'translation', 'pronunciation']); + const structuredLyric = z.object({ + agents: z.array(lyricAgent).optional(), + cueLine: z.array(lyricCueLine).optional(), displayArtist: z.string().optional(), displayTitle: z.string().optional(), + kind: structuredLyricKind.optional(), lang: z.string(), line: z.array(lyricLine), offset: z.number().optional(), diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 34ed0f7b5..84b0ff6a3 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1349,17 +1349,25 @@ export type InternetProviderLyricSearchResponse = { source: LyricSource; }; +export type LyricAgent = { + id: string; + name?: string; + role: 'bg' | 'group' | 'main' | 'voice'; +}; + export type LyricOverride = Omit; export type LyricsArgs = BaseEndpointArgs & { query: LyricsQuery; }; +export type LyricsKind = 'main' | 'pronunciation' | 'translation'; + export type LyricsQuery = { songId: string; }; -export type LyricsResponse = string | SynchronizedLyricsArray; +export type LyricsResponse = string | SynchronizedLyrics; export type RandomSongListArgs = BaseEndpointArgs & { query: RandomSongListQuery; @@ -1435,7 +1443,34 @@ export type SearchSongsQuery = { songStartIndex?: number; }; -export type SynchronizedLyricsArray = Array<[number, string]>; +export type SyncedCueLine = { + agentId?: string; + endMs: number; + index: number; + startMs: number; + value: string; + words: SyncedWordCue[]; +}; + +export type SyncedWordCue = { + endMs: number; + startMs: number; + text: string; +}; + +export type SynchronizedLyricLine = { + cueLines?: SyncedCueLine[]; + startMs: number; + text: string; +}; + +export type SynchronizedLyrics = SynchronizedLyricLine[]; + +/** @deprecated Use SynchronizedLyrics instead */ +export type SynchronizedLyricsArray = SynchronizedLyrics; + +/** @deprecated Use SynchronizedLyrics instead */ +export type SynchronizedLyricsLineTuple = [number, string]; export type TopSongListArgs = BaseEndpointArgs & { query: TopSongListQuery }; @@ -1825,7 +1860,9 @@ export type StructuredLyricsArgs = BaseEndpointArgs & { }; export type StructuredSyncedLyric = Omit & { - lyrics: SynchronizedLyricsArray; + agents?: LyricAgent[]; + kind?: LyricsKind; + lyrics: SynchronizedLyrics; synced: true; };