mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-12 05:30:12 +02:00
implement OS enhanced/karaoke lyrics
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<void> = null;
|
||||
|
||||
@@ -39,6 +50,20 @@ export const convertFurigana = async (text: string): Promise<string> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const convertFuriganaFragment = async (text: string): Promise<string> => {
|
||||
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<string> => {
|
||||
const KuroshiroClass = (Kuroshiro as any).default || Kuroshiro;
|
||||
|
||||
@@ -52,3 +77,70 @@ export const convertRomaji = async (text: string): Promise<string> => {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const parseLyricsTextTokens = async (text: string): Promise<LyricTextToken[]> => {
|
||||
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<RomajiToken[]> => {
|
||||
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 [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -30,15 +30,30 @@ const convertFurigana = (text: string): Promise<string> => {
|
||||
return ipcRenderer.invoke('lyric-convert-furigana', text);
|
||||
};
|
||||
|
||||
const convertFuriganaFragment = (text: string): Promise<string> => {
|
||||
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<string> => {
|
||||
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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof ssType._response.structuredLyrics>['lyricsList']
|
||||
>['structuredLyrics'] extends Array<infer T> | undefined
|
||||
? T
|
||||
: never;
|
||||
|
||||
const mapCueWords = (
|
||||
apiCueLine: NonNullable<ApiStructuredLyric['cueLine']>[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<ApiStructuredLyric['cueLine']>[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<ApiStructuredLyric['cueLine']>,
|
||||
): SynchronizedLyricLine[] => {
|
||||
const cueLinesByIndex = new Map<number, SyncedCueLine[]>();
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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 = /^<ruby>([^<]*)<rp>[^<]*<\/rp><rt>([^<]*)<\/rt><rp>[^<]*<\/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 =>
|
||||
`<ruby>${base}<rp>(</rp><rt>${reading}</rt><rp>)</rp></ruby>`;
|
||||
|
||||
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('<ruby>', 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('</ruby>', 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<string>,
|
||||
): Promise<null | SyncedWordCue[]> => {
|
||||
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;
|
||||
};
|
||||
@@ -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<FullLyricsMetadata | null | StructuredLyric[]> {
|
||||
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<LyricsQueryResult>(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'
|
||||
|
||||
@@ -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);
|
||||
@@ -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<HTMLElement, number>();
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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})`;
|
||||
};
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) ? (
|
||||
<SynchronizedLyrics
|
||||
style={{ padding: 0 }}
|
||||
{...({
|
||||
artist: selectedResult.artist,
|
||||
lyrics: previewData,
|
||||
name: selectedResult.name,
|
||||
remote: true,
|
||||
source: selectedResult.source,
|
||||
} as SynchronizedLyricsProps)}
|
||||
/>
|
||||
lyricsHasWordCues(previewData) ? (
|
||||
<SynchronizedKaraokeLyrics
|
||||
style={{ padding: 0 }}
|
||||
{...({
|
||||
artist: selectedResult.artist,
|
||||
lyrics: previewData,
|
||||
name: selectedResult.name,
|
||||
remote: true,
|
||||
source: selectedResult.source,
|
||||
} as SynchronizedLyricsProps)}
|
||||
/>
|
||||
) : (
|
||||
<SynchronizedLyrics
|
||||
style={{ padding: 0 }}
|
||||
{...({
|
||||
artist: selectedResult.artist,
|
||||
lyrics: previewData,
|
||||
name: selectedResult.name,
|
||||
remote: true,
|
||||
source: selectedResult.source,
|
||||
} as SynchronizedLyricsProps)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<UnsynchronizedLyrics
|
||||
{...({
|
||||
|
||||
@@ -178,6 +178,23 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
|
||||
description: '',
|
||||
title: t('page.fullscreenPlayer.config.followCurrentLyric'),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<NumberInput
|
||||
defaultValue={lyricsSettings.lineLeadTimeMs}
|
||||
max={3000}
|
||||
min={0}
|
||||
onBlur={(e) => {
|
||||
const value = Number(e.currentTarget.value);
|
||||
updateLyricsSetting({ lineLeadTimeMs: value });
|
||||
}}
|
||||
step={50}
|
||||
width={100}
|
||||
/>
|
||||
),
|
||||
description: '',
|
||||
title: t('page.fullscreenPlayer.config.lyricLineLeadTime'),
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Slider
|
||||
|
||||
@@ -0,0 +1,820 @@
|
||||
import {
|
||||
animateScrollTop,
|
||||
computeScrollDurationMs,
|
||||
} from '/@/renderer/features/lyrics/api/lyrics-scroll';
|
||||
import {
|
||||
getLineEndMs,
|
||||
getLyricLineStartMs,
|
||||
lyricsHasWordCues,
|
||||
} from '/@/renderer/features/lyrics/api/lyrics-utils';
|
||||
import { SynchronizedLyrics } from '/@/shared/types/domain-types';
|
||||
|
||||
const ANIMATING_CLASS = 'lyrics-word-animating';
|
||||
const PRE_ANIMATING_CLASS = 'lyrics-word-pre-animating';
|
||||
const PAUSED_CLASS = 'lyrics-word-paused';
|
||||
const LINE_ACTIVE_CLASS = 'lyrics-line-active';
|
||||
const LINE_ANIMATING_CLASS = 'lyrics-line-animating';
|
||||
const LINE_PRE_ANIMATING_CLASS = 'lyrics-line-pre-animating';
|
||||
|
||||
const TIME_JUMP_THRESHOLD = 0.5;
|
||||
const DEFAULT_ENDING_THRESHOLD = 0.5;
|
||||
const DEFAULT_LINE_LEAD_TIME_MS = 800;
|
||||
const DEFAULT_SCROLL_POS_RATIO = 0.37;
|
||||
const RICHSYNC_TIMING_OFFSET_MS = 150;
|
||||
const SYNC_TIMING_OFFSET_MS = 115;
|
||||
const GLOW_DURATION_MULTIPLIER = 1.6;
|
||||
|
||||
export interface AnimEngineState {
|
||||
lastActiveElements: LineData[];
|
||||
lastEventCreationTime: number;
|
||||
lastPlayState: boolean;
|
||||
lastTime: number;
|
||||
scroll: ScrollState;
|
||||
selectedElementIndex: number;
|
||||
}
|
||||
|
||||
export interface BuildLyricsDataOptions {
|
||||
container: HTMLElement;
|
||||
lineIdPrefix: 'karaoke-line' | 'lyric';
|
||||
lyrics: SynchronizedLyrics;
|
||||
}
|
||||
|
||||
export interface LineData {
|
||||
accumulatedOffsetMs: number;
|
||||
animationStartTimeMs: number;
|
||||
duration: number;
|
||||
element: HTMLElement;
|
||||
hasWordCues: boolean;
|
||||
height: number;
|
||||
isAnimating: boolean;
|
||||
isAnimationPlayStatePlaying: boolean;
|
||||
isScrolled: boolean;
|
||||
isSelected: boolean;
|
||||
lastAnimSetupAt: number;
|
||||
parts: PartData[];
|
||||
position: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface LyricsData {
|
||||
container: HTMLElement;
|
||||
lines: LineData[];
|
||||
syncType: LyricsSyncType;
|
||||
}
|
||||
|
||||
export type LyricsSyncType = 'none' | 'richsync' | 'synced';
|
||||
|
||||
export interface PartData {
|
||||
animationStartTimeMs: number;
|
||||
duration: number;
|
||||
element: HTMLElement;
|
||||
isAnimating: boolean;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface ScrollState {
|
||||
cancelScroll: (() => 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<HTMLElement>(
|
||||
'.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<HTMLElement>('.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<HTMLElement>('.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);
|
||||
}
|
||||
};
|
||||
@@ -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<SynchronizedLyrics> => {
|
||||
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<string>,
|
||||
): Promise<SynchronizedLyrics> =>
|
||||
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<null | SyncedCueLine[]> => {
|
||||
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<null | SyncedRomajiLyrics> => {
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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<AnimEngineState>;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
enabled?: boolean;
|
||||
followRef?: React.RefObject<boolean>;
|
||||
lineIdPrefix: 'karaoke-line' | 'lyric';
|
||||
lineLeadTimeMsRef?: React.RefObject<number>;
|
||||
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<AnimEngineState>(createAnimEngineState());
|
||||
const animStateRef = externalAnimStateRef ?? internalAnimStateRef;
|
||||
const lyricsDataRef = useRef<LyricsData | null>(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,
|
||||
};
|
||||
};
|
||||
@@ -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 | ReturnType<typeof setTimeout>>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const programmaticScrollRef = useRef(false);
|
||||
const programmaticScrollTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);
|
||||
const lyricRef = useRef<null | SynchronizedLyrics>(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<HTMLDivElement>) => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = /<ruby[\s>]/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 (
|
||||
<span
|
||||
className={clsx(styles.karaokeWord, isRomaji && 'karaoke-romaji-word')}
|
||||
dangerouslySetInnerHTML={{ __html: sanitize(cueLine.value) }}
|
||||
data-lyric-time={cueLine.startMs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span key={wordKey} {...wordProps}>
|
||||
<span
|
||||
className="karaoke-word-text"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="karaoke-word-highlight"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={wordKey}
|
||||
{...wordProps}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
|
||||
data-content={word.text}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
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 (
|
||||
<Box
|
||||
className={clsx(styles.karaokeLine, 'karaoke-line', className)}
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
<Stack align="stretch" gap={0} w="100%">
|
||||
{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 (
|
||||
<div
|
||||
className={styles.agentLine}
|
||||
data-agent-role={agent?.role}
|
||||
key={`${cueLine.index}-${cueLineIndex}`}
|
||||
>
|
||||
{agentLabel && (
|
||||
<span className={styles.agentBadge}>{agentLabel}</span>
|
||||
)}
|
||||
<span
|
||||
className={styles.agentText}
|
||||
id={
|
||||
cueLineIndex === 0
|
||||
? `karaoke-anchor-${lineIndex}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{renderWordSpans(
|
||||
cueLine,
|
||||
lineIndex,
|
||||
cueLineIndex,
|
||||
isBackground,
|
||||
'main',
|
||||
)}
|
||||
</span>
|
||||
{romajiCueLine && (
|
||||
<span className={styles.romajiLine}>
|
||||
{renderWordSpans(
|
||||
romajiCueLine,
|
||||
lineIndex,
|
||||
cueLineIndex,
|
||||
false,
|
||||
'romaji',
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!hasSyncedRomaji && romajiText && (
|
||||
<span
|
||||
className={styles.romajiLine}
|
||||
dangerouslySetInnerHTML={{ __html: sanitize(romajiText) }}
|
||||
/>
|
||||
)}
|
||||
{translatedText && (
|
||||
<span dangerouslySetInnerHTML={{ __html: sanitize(translatedText) }} />
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
KaraokeLyricLine.displayName = 'KaraokeLyricLine';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
() => ({
|
||||
|
||||
@@ -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 = ({
|
||||
</Group>
|
||||
|
||||
<div style={{ position: 'absolute', right: 0, top: -50 }}>
|
||||
{isDesktop && sources.length && onTranslateLyric ? (
|
||||
<Button
|
||||
disabled={isActionsDisabled}
|
||||
onClick={onTranslateLyric}
|
||||
uppercase
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.translation')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Group gap="xs">
|
||||
{hasTranslationLayer && onToggleTranslationLayer ? (
|
||||
<Button
|
||||
disabled={isActionsDisabled}
|
||||
onClick={onToggleTranslationLayer}
|
||||
uppercase
|
||||
variant={showTranslationLayer ? 'filled' : 'subtle'}
|
||||
>
|
||||
{t('common.translation')}
|
||||
</Button>
|
||||
) : null}
|
||||
{hasPronunciationLayer && onTogglePronunciationLayer ? (
|
||||
<Button
|
||||
disabled={isActionsDisabled}
|
||||
onClick={onTogglePronunciationLayer}
|
||||
uppercase
|
||||
variant={showPronunciationLayer ? 'filled' : 'subtle'}
|
||||
>
|
||||
Pronunciation
|
||||
</Button>
|
||||
) : null}
|
||||
{isDesktop && sources.length && onTranslateLyric && !hasTranslationLayer ? (
|
||||
<Button
|
||||
disabled={isActionsDisabled}
|
||||
onClick={onTranslateLyric}
|
||||
uppercase
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.translation')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 | string>(null);
|
||||
const [showTranslation, setShowTranslation] = useState(false);
|
||||
const [showTranslationLayer, setShowTranslationLayer] = useState(false);
|
||||
const [showPronunciationLayer, setShowPronunciationLayer] = useState(false);
|
||||
const [pendingSongId, setPendingSongId] = useState<string | undefined>(currentSong?.id);
|
||||
const lyricsFetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const previousSongIdRef = useRef<string | undefined>(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 ? (
|
||||
<SynchronizedLyrics
|
||||
{...(displayLyrics as SynchronizedLyricsProps)}
|
||||
offsetMs={displayOffsetMs}
|
||||
romajiLyrics={
|
||||
enableRomaji
|
||||
? (romajiConvertedLyrics as SynchronizedLyricsProps['romajiLyrics'])
|
||||
: null
|
||||
}
|
||||
settingsKey={settingsKey}
|
||||
translatedLyrics={showTranslation ? translatedLyrics : null}
|
||||
/>
|
||||
{synced && syncedLyricsProps ? (
|
||||
isKaraoke ? (
|
||||
<SynchronizedKaraokeLyrics
|
||||
{...syncedLyricsProps}
|
||||
agents={selectedAgents}
|
||||
/>
|
||||
) : (
|
||||
<SynchronizedLyrics {...syncedLyricsProps} />
|
||||
)
|
||||
) : (
|
||||
<UnsynchronizedLyrics
|
||||
{...(displayLyrics as UnsynchronizedLyricsProps)}
|
||||
@@ -376,12 +479,20 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
||||
<div className={styles.actionsContainer}>
|
||||
<LyricsActions
|
||||
hasLyrics={!!displayLyrics}
|
||||
hasPronunciationLayer={!!layers?.pronunciation?.synced}
|
||||
hasTranslationLayer={!!layers?.translation?.synced}
|
||||
index={indexToUse}
|
||||
languages={languages}
|
||||
offsetMs={displayOffsetMs}
|
||||
onExportLyrics={handleExportLyrics}
|
||||
onRemoveLyric={handleOnRemoveLyric}
|
||||
onSearchOverride={handleOnSearchOverride}
|
||||
onTogglePronunciationLayer={() =>
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/* Register animatable custom properties for gradient swipe */
|
||||
@property --lyric-transition-amount-start {
|
||||
syntax: '<number>';
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
|
||||
@property --lyric-transition-amount-end {
|
||||
syntax: '<number>';
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<FullLyricsMetadata, 'lyrics'> {
|
||||
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 | number>(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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div
|
||||
className={clsx(styles.container, 'synchronized-karaoke-lyrics overlay-scrollbar')}
|
||||
id={LYRICS_SCROLL_CONTAINER_ID}
|
||||
onClick={handleContainerClick}
|
||||
onMouseEnter={showScrollbar}
|
||||
onMouseLeave={hideScrollbar}
|
||||
ref={containerRef}
|
||||
style={{ ...containerStyle, ...style }}
|
||||
>
|
||||
{settings.showProvider && source && (
|
||||
<LyricLine
|
||||
alignment={settings.alignment}
|
||||
className="lyric-credit"
|
||||
fontSize={settings.fontSize}
|
||||
text={`Provided by ${source}`}
|
||||
/>
|
||||
)}
|
||||
{settings.showMatch && remote && (
|
||||
<LyricLine
|
||||
alignment={settings.alignment}
|
||||
className="lyric-credit"
|
||||
fontSize={settings.fontSize}
|
||||
text={`"${name} by ${artist}"`}
|
||||
/>
|
||||
)}
|
||||
{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 (
|
||||
<LyricLine
|
||||
alignment={settings.alignment}
|
||||
className="lyric-line synchronized"
|
||||
data-lyric-time={lineStartMs}
|
||||
fontSize={settings.fontSize}
|
||||
id={`karaoke-line-${idx}`}
|
||||
key={idx}
|
||||
romajiText={pronunciationText}
|
||||
text={getLyricLineText(rawLine)}
|
||||
translatedText={translationText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KaraokeLyricLine
|
||||
agents={agents}
|
||||
alignment={settings.alignment}
|
||||
className="synchronized"
|
||||
cueLines={rawLine.cueLines}
|
||||
data-lyric-time={lineStartMs}
|
||||
fontSize={settings.fontSize}
|
||||
id={`karaoke-line-${idx}`}
|
||||
key={idx}
|
||||
lineIndex={idx}
|
||||
romajiCueLines={syncedRomajiLyrics?.[idx] ?? null}
|
||||
romajiText={pronunciationText}
|
||||
translatedText={translationText}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<FullLyricsMetadata, 'lyrics'> {
|
||||
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<HTMLDivElement>) => {
|
||||
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 | ReturnType<typeof setTimeout>>(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 | SynchronizedLyricsArray>(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 | number>(null);
|
||||
const statusRef = useRef(usePlayerStoreBase.getState().player.status);
|
||||
const userScrollingRef = useRef(false);
|
||||
const scrollTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const programmaticScrollRef = useRef(false);
|
||||
const programmaticScrollTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div
|
||||
className={clsx(styles.container, 'synchronized-lyrics overlay-scrollbar')}
|
||||
id="sychronized-lyrics-scroll-container"
|
||||
id={LYRICS_SCROLL_CONTAINER_ID}
|
||||
onClick={handleContainerClick}
|
||||
onMouseEnter={showScrollbar}
|
||||
onMouseLeave={hideScrollbar}
|
||||
ref={containerRef}
|
||||
style={
|
||||
{
|
||||
// opacity/scale is set here for every lyric,
|
||||
// and then overwritten by CSS for active lyrics
|
||||
// to prevent expensive rerenders each lyric
|
||||
'--lyric-opacity': settings.opacityNonActive,
|
||||
'--lyric-scale': settings.scaleNonActive,
|
||||
'--lyric-scale-origin': settings.alignment,
|
||||
gap: `${settings.gap}px`,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
style={{ ...containerStyle, ...style }}
|
||||
>
|
||||
{settings.showProvider && source && (
|
||||
<LyricLine
|
||||
@@ -397,19 +251,34 @@ export const SynchronizedLyrics = ({
|
||||
text={`"${name} by ${artist}"`}
|
||||
/>
|
||||
)}
|
||||
{lyrics.map(([time, text], idx) => (
|
||||
<LyricLine
|
||||
alignment={settings.alignment}
|
||||
className="lyric-line synchronized"
|
||||
data-lyric-time={time}
|
||||
fontSize={settings.fontSize}
|
||||
id={`lyric-${idx}`}
|
||||
key={idx}
|
||||
romajiText={romajiLyrics?.[idx]?.[1]}
|
||||
text={text}
|
||||
translatedText={translatedLyrics?.split('\n')[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 (
|
||||
<LyricLine
|
||||
alignment={settings.alignment}
|
||||
className="lyric-line synchronized"
|
||||
data-lyric-time={lineStartMs}
|
||||
fontSize={settings.fontSize}
|
||||
id={`lyric-${idx}`}
|
||||
key={idx}
|
||||
romajiText={pronunciationText}
|
||||
text={lineText}
|
||||
translatedText={translationText}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Vendored
+1
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<InternetProviderLyricResponse, 'lyrics'>;
|
||||
|
||||
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<FullLyricsMetadata, 'lyrics'> & {
|
||||
lyrics: SynchronizedLyricsArray;
|
||||
agents?: LyricAgent[];
|
||||
kind?: LyricsKind;
|
||||
lyrics: SynchronizedLyrics;
|
||||
synced: true;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user