mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-18 16:36:29 +02:00
support additional lyric layers (#2211)
This commit is contained in:
@@ -567,6 +567,10 @@
|
||||
"useImageAspectRatio": "Use image aspect ratio"
|
||||
},
|
||||
"lyrics": "Lyrics",
|
||||
"lyricLayers": "Lyric layers",
|
||||
"lyricLanguage": "Language",
|
||||
"showPronunciation": "Pronunciation",
|
||||
"showTranslation": "Translation",
|
||||
"related": "Related",
|
||||
"upNext": "Up next",
|
||||
"visualizer": "Visualizer",
|
||||
|
||||
@@ -100,14 +100,22 @@ export const getLineEndMs = (line: SynchronizedLyricLine): number => {
|
||||
export const lyricsHasWordCues = (lyrics: SynchronizedLyrics): boolean =>
|
||||
lyrics.some((line) => line.cueLines?.some((cueLine) => cueLine.words.length > 0));
|
||||
|
||||
export const findOverlayLineByTime = (
|
||||
export const findOverlayLineMatchByTime = (
|
||||
overlayLyrics: null | SynchronizedLyrics | undefined,
|
||||
startMs: number,
|
||||
): string | undefined => {
|
||||
lineIndex?: number,
|
||||
): SynchronizedLyricLine | undefined => {
|
||||
if (!overlayLyrics?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lineIndex !== undefined && lineIndex >= 0 && lineIndex < overlayLyrics.length) {
|
||||
const indexedLine = normalizeLyricsLine(overlayLyrics[lineIndex]);
|
||||
if (indexedLine.startMs <= startMs) {
|
||||
return indexedLine;
|
||||
}
|
||||
}
|
||||
|
||||
let match: SynchronizedLyricLine | undefined;
|
||||
|
||||
for (const rawLine of overlayLyrics) {
|
||||
@@ -120,12 +128,36 @@ export const findOverlayLineByTime = (
|
||||
break;
|
||||
}
|
||||
|
||||
return match?.text;
|
||||
return match;
|
||||
};
|
||||
|
||||
export const findOverlayLineByTime = (
|
||||
overlayLyrics: null | SynchronizedLyrics | undefined,
|
||||
startMs: number,
|
||||
lineIndex?: number,
|
||||
): string | undefined => findOverlayLineMatchByTime(overlayLyrics, startMs, lineIndex)?.text;
|
||||
|
||||
export const overlayLineHasWordCues = (line: SynchronizedLyricLine | undefined): boolean =>
|
||||
!!line?.cueLines?.some((cueLine) => cueLine.words.length > 0);
|
||||
|
||||
export const getOverlayCueLinesForLine = (
|
||||
overlayLyrics: null | SynchronizedLyrics | undefined,
|
||||
mainLineStartMs: number,
|
||||
lineIndex?: number,
|
||||
): null | SyncedCueLine[] => {
|
||||
const match = findOverlayLineMatchByTime(overlayLyrics, mainLineStartMs, lineIndex);
|
||||
|
||||
if (!match?.cueLines?.length || !overlayLineHasWordCues(match)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match.cueLines;
|
||||
};
|
||||
|
||||
export type LyricsLayers = {
|
||||
main: StructuredLyric[];
|
||||
others: StructuredLyric[];
|
||||
overlayLayers: StructuredLyric[];
|
||||
pronunciation: null | StructuredLyric;
|
||||
translation: null | StructuredLyric;
|
||||
};
|
||||
@@ -141,6 +173,7 @@ const getStructuredKind = (lyric: StructuredLyric): LyricsKind => {
|
||||
export const getLyricsLayers = (local: StructuredLyric[]): LyricsLayers => {
|
||||
const main: StructuredLyric[] = [];
|
||||
const others: StructuredLyric[] = [];
|
||||
const overlayLayers: StructuredLyric[] = [];
|
||||
let pronunciation: null | StructuredLyric = null;
|
||||
let translation: null | StructuredLyric = null;
|
||||
|
||||
@@ -151,6 +184,10 @@ export const getLyricsLayers = (local: StructuredLyric[]): LyricsLayers => {
|
||||
main.push(lyric);
|
||||
} else {
|
||||
others.push(lyric);
|
||||
|
||||
if (lyric.synced) {
|
||||
overlayLayers.push(lyric);
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === 'translation' && !translation) {
|
||||
@@ -162,7 +199,7 @@ export const getLyricsLayers = (local: StructuredLyric[]): LyricsLayers => {
|
||||
}
|
||||
}
|
||||
|
||||
return { main, others, pronunciation, translation };
|
||||
return { main, others, overlayLayers, pronunciation, translation };
|
||||
};
|
||||
|
||||
export const getDefaultStructuredIndex = (local: StructuredLyric[]): number => {
|
||||
@@ -173,6 +210,11 @@ export const getDefaultStructuredIndex = (local: StructuredLyric[]): number => {
|
||||
return mainIndex >= 0 ? mainIndex : 0;
|
||||
};
|
||||
|
||||
export const getOverlayLayerKey = (lyric: StructuredLyric): string => {
|
||||
const kind = getStructuredKind(lyric);
|
||||
return `${kind}:${lyric.lang}`;
|
||||
};
|
||||
|
||||
export const formatStructuredLyricLabel = (lyric: StructuredLyric): string => {
|
||||
const kind = getStructuredKind(lyric);
|
||||
return kind === 'main' ? lyric.lang : `${lyric.lang} (${kind})`;
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface LineData {
|
||||
isScrolled: boolean;
|
||||
isSelected: boolean;
|
||||
lastAnimSetupAt: number;
|
||||
overlayParts: PartData[];
|
||||
parts: PartData[];
|
||||
position: number;
|
||||
time: number;
|
||||
@@ -143,7 +144,7 @@ export const resetAnimEngine = (state: AnimEngineState): void => {
|
||||
|
||||
const collectLineParts = (lineElement: HTMLElement): PartData[] => {
|
||||
const words = lineElement.querySelectorAll<HTMLElement>(
|
||||
'.karaoke-word[data-duration]:not(.karaoke-romaji-word)',
|
||||
'.karaoke-word[data-duration]:not(.karaoke-overlay-word)',
|
||||
);
|
||||
|
||||
return Array.from(words).map((element) => ({
|
||||
@@ -155,32 +156,18 @@ const collectLineParts = (lineElement: HTMLElement): PartData[] => {
|
||||
}));
|
||||
};
|
||||
|
||||
const getRomajiCounterparts = (mainElement: HTMLElement): HTMLElement[] => {
|
||||
const line = mainElement.closest('.karaoke-line');
|
||||
if (!line) {
|
||||
return [];
|
||||
}
|
||||
const collectOverlayParts = (lineElement: HTMLElement): PartData[] => {
|
||||
const words = lineElement.querySelectorAll<HTMLElement>(
|
||||
'.karaoke-overlay-word[data-duration]',
|
||||
);
|
||||
|
||||
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;
|
||||
});
|
||||
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 cancelSungAnimationCleanup = (element: HTMLElement): void => {
|
||||
@@ -246,79 +233,78 @@ const clearElementAnimation = (element: HTMLElement): void => {
|
||||
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 clearWordSungState = (part: PartData): void => {
|
||||
part.element.classList.remove('sung');
|
||||
};
|
||||
|
||||
const syncRomajiAnimation = (
|
||||
mainElement: HTMLElement,
|
||||
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);
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const animateWordParts = (
|
||||
parts: PartData[],
|
||||
interpolatedTimeSec: number,
|
||||
action: 'clear' | 'clearSung' | 'pause' | 'resume' | 'setup' | 'sung',
|
||||
now: number,
|
||||
): 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;
|
||||
for (const part of parts) {
|
||||
const partEnd = part.time + part.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;
|
||||
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);
|
||||
}
|
||||
} else if (!part.isAnimating) {
|
||||
setupPartAnimation(part, interpolatedTimeSec, now);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearLineRomajiState = (lineElement: HTMLElement): void => {
|
||||
for (const element of lineElement.querySelectorAll<HTMLElement>('.karaoke-romaji-word')) {
|
||||
const clearWordParts = (parts: PartData[], interpolatedTimeSec: number): void => {
|
||||
for (const part of parts) {
|
||||
if (interpolatedTimeSec >= part.time + part.duration) {
|
||||
markWordSung(part, interpolatedTimeSec);
|
||||
} else {
|
||||
clearPartAnimation(part);
|
||||
clearWordSungState(part);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resumeWordParts = (parts: PartData[]): void => {
|
||||
for (const part of parts) {
|
||||
part.element.classList.remove(PAUSED_CLASS);
|
||||
}
|
||||
};
|
||||
|
||||
const clearLineOverlayState = (lineElement: HTMLElement): void => {
|
||||
for (const element of lineElement.querySelectorAll<HTMLElement>('.karaoke-overlay-word')) {
|
||||
clearElementAnimation(element);
|
||||
element.classList.remove('sung');
|
||||
}
|
||||
@@ -343,6 +329,7 @@ export const buildLyricsDataFromDom = ({
|
||||
const endMs = getLineEndMs(line);
|
||||
const durationMs = Math.max(0, endMs - startMs);
|
||||
const parts = hasWordCues && line.cueLines?.length ? collectLineParts(lineElement) : [];
|
||||
const overlayParts = collectOverlayParts(lineElement);
|
||||
|
||||
lines.push({
|
||||
accumulatedOffsetMs: 0,
|
||||
@@ -356,6 +343,7 @@ export const buildLyricsDataFromDom = ({
|
||||
isScrolled: false,
|
||||
isSelected: false,
|
||||
lastAnimSetupAt: 0,
|
||||
overlayParts,
|
||||
parts,
|
||||
position: -1,
|
||||
time: startMs / 1000,
|
||||
@@ -411,37 +399,23 @@ const clearLineAnimation = (line: LineData): void => {
|
||||
line.animationStartTimeMs = Number.POSITIVE_INFINITY;
|
||||
};
|
||||
|
||||
const clearWordSungState = (part: PartData): void => {
|
||||
part.element.classList.remove('sung');
|
||||
syncRomajiAnimation(part.element, 0, 'clearSung');
|
||||
};
|
||||
const clearLineKaraokeHighlights = (lineData: LineData, interpolatedTimeSec: number): void => {
|
||||
if (lineData.hasWordCues) {
|
||||
for (const part of lineData.parts) {
|
||||
clearPartAnimation(part);
|
||||
clearWordSungState(part);
|
||||
}
|
||||
} else {
|
||||
clearLineAnimation(lineData);
|
||||
}
|
||||
|
||||
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');
|
||||
};
|
||||
if (lineData.overlayParts.length) {
|
||||
clearWordParts(lineData.overlayParts, interpolatedTimeSec);
|
||||
}
|
||||
|
||||
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');
|
||||
lineData.isSelected = false;
|
||||
lineData.isAnimating = false;
|
||||
lineData.isAnimationPlayStatePlaying = false;
|
||||
};
|
||||
|
||||
const setupLineAnimation = (line: LineData, interpolatedTimeSec: number, now: number): void => {
|
||||
@@ -463,22 +437,6 @@ const setupLineAnimation = (line: LineData, interpolatedTimeSec: number, now: nu
|
||||
line.accumulatedOffsetMs = 0;
|
||||
};
|
||||
|
||||
const clearLineKaraokeHighlights = (lineData: LineData, interpolatedTimeSec: number): void => {
|
||||
if (lineData.hasWordCues) {
|
||||
for (const part of lineData.parts) {
|
||||
clearPartAnimation(part);
|
||||
clearWordSungState(part);
|
||||
syncRomajiAnimation(part.element, interpolatedTimeSec, 'clear');
|
||||
}
|
||||
} else {
|
||||
clearLineAnimation(lineData);
|
||||
}
|
||||
|
||||
lineData.isSelected = false;
|
||||
lineData.isAnimating = false;
|
||||
lineData.isAnimationPlayStatePlaying = false;
|
||||
};
|
||||
|
||||
const decaySkipScrolls = (state: ScrollState, now: number): void => {
|
||||
let decayCount = 0;
|
||||
|
||||
@@ -593,10 +551,16 @@ export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions):
|
||||
clearPartAnimation(part);
|
||||
clearWordSungState(part);
|
||||
}
|
||||
clearLineRomajiState(lineData.element);
|
||||
} else {
|
||||
clearLineAnimation(lineData);
|
||||
}
|
||||
|
||||
if (lineData.overlayParts.length) {
|
||||
for (const part of lineData.overlayParts) {
|
||||
clearPartAnimation(part);
|
||||
clearWordSungState(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.scroll.pendingScroll = true;
|
||||
@@ -698,14 +662,15 @@ export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions):
|
||||
lineData.isSelected = true;
|
||||
|
||||
if (lineData.hasWordCues) {
|
||||
for (const part of lineData.parts) {
|
||||
part.element.classList.remove(PAUSED_CLASS);
|
||||
syncRomajiAnimation(part.element, interpolatedTimeSec, 'resume');
|
||||
}
|
||||
resumeWordParts(lineData.parts);
|
||||
} else {
|
||||
lineData.element.classList.remove(PAUSED_CLASS);
|
||||
}
|
||||
|
||||
if (lineData.overlayParts.length) {
|
||||
resumeWordParts(lineData.overlayParts);
|
||||
}
|
||||
|
||||
lineData.isAnimationPlayStatePlaying = true;
|
||||
|
||||
if (!lineData.hasWordCues && !lineData.isAnimating) {
|
||||
@@ -713,39 +678,23 @@ export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions):
|
||||
}
|
||||
|
||||
if (lineData.hasWordCues) {
|
||||
for (const part of lineData.parts) {
|
||||
const partEnd = part.time + part.duration;
|
||||
animateWordParts(lineData.parts, interpolatedTimeSec, now);
|
||||
}
|
||||
|
||||
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 (!part.isAnimating) {
|
||||
setupPartAnimation(part, interpolatedTimeSec, now);
|
||||
}
|
||||
}
|
||||
if (lineData.overlayParts.length) {
|
||||
animateWordParts(lineData.overlayParts, 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');
|
||||
}
|
||||
}
|
||||
clearWordParts(lineData.parts, interpolatedTimeSec);
|
||||
} else {
|
||||
clearLineAnimation(lineData);
|
||||
}
|
||||
|
||||
if (lineData.overlayParts.length) {
|
||||
clearWordParts(lineData.overlayParts, interpolatedTimeSec);
|
||||
}
|
||||
|
||||
lineData.isSelected = false;
|
||||
lineData.isAnimating = false;
|
||||
lineData.isAnimationPlayStatePlaying = false;
|
||||
@@ -861,7 +810,12 @@ export const resetLyricsAnimationDom = (lyricsData: LyricsData | null): void =>
|
||||
clearWordSungState(part);
|
||||
}
|
||||
|
||||
clearLineRomajiState(line.element);
|
||||
for (const part of line.overlayParts) {
|
||||
clearPartAnimation(part);
|
||||
clearWordSungState(part);
|
||||
}
|
||||
|
||||
clearLineOverlayState(line.element);
|
||||
clearLineAnimation(line);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.romaji-line {
|
||||
.overlay-line {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
font-size: 0.8em;
|
||||
@@ -44,6 +44,15 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.translation-line {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.agent-line {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
@@ -19,15 +19,60 @@ interface KaraokeLyricLineProps extends ComponentPropsWithoutRef<'div'> {
|
||||
agents?: LyricAgent[];
|
||||
alignment: 'center' | 'left' | 'right';
|
||||
cueLines: SyncedCueLine[];
|
||||
extraOverlays?: Array<{
|
||||
cueLines: null | SyncedCueLine[];
|
||||
text?: null | string;
|
||||
}>;
|
||||
fontSize: number;
|
||||
lineIndex: number;
|
||||
pronunciationCueLines?: null | SyncedCueLine[];
|
||||
pronunciationText?: null | string;
|
||||
romajiCueLines?: (null | SyncedCueLine)[] | null;
|
||||
romajiText?: null | string;
|
||||
text?: string;
|
||||
translatedText?: null | string;
|
||||
translationCueLines?: null | SyncedCueLine[];
|
||||
}
|
||||
|
||||
type WordSpanVariant = 'main' | 'romaji';
|
||||
type WordSpanVariant = 'main' | 'overlay-generic' | 'overlay-pronunciation' | 'overlay-translation';
|
||||
|
||||
const getWordSpanClasses = (
|
||||
variant: WordSpanVariant,
|
||||
options: {
|
||||
hasFurigana: boolean;
|
||||
isBackground: boolean;
|
||||
isRtl: boolean;
|
||||
isZeroDuration: boolean;
|
||||
},
|
||||
) => {
|
||||
const isOverlay = variant !== 'main';
|
||||
|
||||
return clsx(
|
||||
styles.karaokeWord,
|
||||
'karaoke-word',
|
||||
isOverlay && 'karaoke-overlay-word',
|
||||
variant === 'overlay-generic' && 'karaoke-overlay-generic',
|
||||
variant === 'overlay-pronunciation' && 'karaoke-overlay-pronunciation',
|
||||
variant === 'overlay-translation' && 'karaoke-overlay-translation',
|
||||
options.isRtl && 'karaoke-rtl',
|
||||
options.isBackground && 'karaoke-bg-vocal',
|
||||
options.isZeroDuration && 'karaoke-zero-dur',
|
||||
options.hasFurigana && 'karaoke-furigana-word',
|
||||
);
|
||||
};
|
||||
|
||||
const getWordSpanIdPrefix = (variant: WordSpanVariant): string => {
|
||||
switch (variant) {
|
||||
case 'overlay-generic':
|
||||
return 'karaoke-overlay-generic';
|
||||
case 'overlay-pronunciation':
|
||||
return 'karaoke-overlay-pronunciation';
|
||||
case 'overlay-translation':
|
||||
return 'karaoke-overlay-translation';
|
||||
default:
|
||||
return 'karaoke';
|
||||
}
|
||||
};
|
||||
|
||||
const renderWordSpans = (
|
||||
cueLine: SyncedCueLine,
|
||||
@@ -36,13 +81,18 @@ const renderWordSpans = (
|
||||
isBackground: boolean,
|
||||
variant: WordSpanVariant = 'main',
|
||||
) => {
|
||||
const isRomaji = variant === 'romaji';
|
||||
const idPrefix = isRomaji ? 'karaoke-romaji' : 'karaoke';
|
||||
const isOverlay = variant !== 'main';
|
||||
const idPrefix = getWordSpanIdPrefix(variant);
|
||||
|
||||
if (!cueLine.words.length) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(styles.karaokeWord, isRomaji && 'karaoke-romaji-word')}
|
||||
className={getWordSpanClasses(variant, {
|
||||
hasFurigana: false,
|
||||
isBackground,
|
||||
isRtl: false,
|
||||
isZeroDuration: false,
|
||||
})}
|
||||
dangerouslySetInnerHTML={{ __html: sanitize(cueLine.value) }}
|
||||
data-lyric-time={cueLine.startMs}
|
||||
/>
|
||||
@@ -50,7 +100,7 @@ const renderWordSpans = (
|
||||
}
|
||||
|
||||
const splitWords =
|
||||
isRomaji || cueLine.words.some((word) => hasFuriganaHtml(word.text))
|
||||
isOverlay || cueLine.words.some((word) => hasFuriganaHtml(word.text))
|
||||
? cueLine.words
|
||||
: splitWordCues(cueLine.words);
|
||||
let wordCounter = 0;
|
||||
@@ -61,20 +111,17 @@ const renderWordSpans = (
|
||||
const timeSec = word.startMs / 1000;
|
||||
const isRtl = testRtl(word.text);
|
||||
const isZeroDuration = durationMs <= 0;
|
||||
const hasFurigana = !isRomaji && hasFuriganaHtml(word.text);
|
||||
const hasFurigana = !isOverlay && 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 wordClassName = getWordSpanClasses(variant, {
|
||||
hasFurigana,
|
||||
isBackground,
|
||||
isRtl,
|
||||
isZeroDuration,
|
||||
});
|
||||
|
||||
const wordKey = `${word.startMs}-${currentWordIndex}`;
|
||||
const wordProps = {
|
||||
@@ -106,17 +153,36 @@ const renderWordSpans = (
|
||||
});
|
||||
};
|
||||
|
||||
const renderOverlayCueLineRow = (
|
||||
cueLine: SyncedCueLine,
|
||||
lineIndex: number,
|
||||
cueLineIndex: number,
|
||||
variant: 'overlay-generic' | 'overlay-pronunciation' | 'overlay-translation',
|
||||
lineClassName: string,
|
||||
) => (
|
||||
<span className={lineClassName}>
|
||||
{renderWordSpans(cueLine, lineIndex, cueLineIndex, false, variant)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const hasSyncedOverlayCueLines = (cueLines: null | SyncedCueLine[] | undefined): boolean =>
|
||||
!!cueLines?.some((cueLine) => cueLine.words.length > 0);
|
||||
|
||||
export const KaraokeLyricLine = memo(
|
||||
({
|
||||
agents,
|
||||
alignment,
|
||||
className,
|
||||
cueLines,
|
||||
extraOverlays,
|
||||
fontSize,
|
||||
lineIndex,
|
||||
pronunciationCueLines,
|
||||
pronunciationText,
|
||||
romajiCueLines,
|
||||
romajiText,
|
||||
translatedText,
|
||||
translationCueLines,
|
||||
...props
|
||||
}: KaraokeLyricLineProps) => {
|
||||
const style = useMemo(
|
||||
@@ -128,6 +194,10 @@ export const KaraokeLyricLine = memo(
|
||||
);
|
||||
|
||||
const hasSyncedRomaji = romajiCueLines != null;
|
||||
const hasSyncedPronunciation = hasSyncedOverlayCueLines(pronunciationCueLines);
|
||||
const hasSyncedTranslation = hasSyncedOverlayCueLines(translationCueLines);
|
||||
const pronunciationFallbackText =
|
||||
!hasSyncedRomaji && !hasSyncedPronunciation ? (pronunciationText ?? romajiText) : null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -140,6 +210,8 @@ export const KaraokeLyricLine = memo(
|
||||
const agent = agents?.find((entry) => entry.id === cueLine.agentId);
|
||||
const isBackground = agent?.role === 'bg' || agent?.role === 'group';
|
||||
const romajiCueLine = romajiCueLines?.[cueLineIndex];
|
||||
const pronunciationCueLine = pronunciationCueLines?.[cueLineIndex];
|
||||
const translationCueLine = translationCueLines?.[cueLineIndex];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -164,28 +236,82 @@ export const KaraokeLyricLine = memo(
|
||||
)}
|
||||
</span>
|
||||
{romajiCueLine && (
|
||||
<span className={styles.romajiLine}>
|
||||
<span className={styles.overlayLine}>
|
||||
{renderWordSpans(
|
||||
romajiCueLine,
|
||||
lineIndex,
|
||||
cueLineIndex,
|
||||
false,
|
||||
'romaji',
|
||||
'overlay-pronunciation',
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{!romajiCueLine &&
|
||||
pronunciationCueLine &&
|
||||
renderOverlayCueLineRow(
|
||||
pronunciationCueLine,
|
||||
lineIndex,
|
||||
cueLineIndex,
|
||||
'overlay-pronunciation',
|
||||
styles.overlayLine,
|
||||
)}
|
||||
{translationCueLine &&
|
||||
renderOverlayCueLineRow(
|
||||
translationCueLine,
|
||||
lineIndex,
|
||||
cueLineIndex,
|
||||
'overlay-translation',
|
||||
styles.translationLine,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!hasSyncedRomaji && romajiText && (
|
||||
{pronunciationFallbackText && (
|
||||
<span
|
||||
className={styles.romajiLine}
|
||||
dangerouslySetInnerHTML={{ __html: sanitize(romajiText) }}
|
||||
className={styles.overlayLine}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitize(pronunciationFallbackText),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{translatedText && (
|
||||
<span dangerouslySetInnerHTML={{ __html: sanitize(translatedText) }} />
|
||||
{!hasSyncedTranslation && translatedText && (
|
||||
<span
|
||||
className={styles.translationLine}
|
||||
dangerouslySetInnerHTML={{ __html: sanitize(translatedText) }}
|
||||
/>
|
||||
)}
|
||||
{extraOverlays?.map((overlay, overlayIndex) => {
|
||||
const hasSyncedExtra = hasSyncedOverlayCueLines(overlay.cueLines);
|
||||
|
||||
if (hasSyncedExtra && overlay.cueLines) {
|
||||
return overlay.cueLines.map((cueLine, cueLineIndex) => (
|
||||
<span
|
||||
className={styles.overlayLine}
|
||||
key={`extra-overlay-${overlayIndex}-cue-${cueLineIndex}`}
|
||||
>
|
||||
{renderWordSpans(
|
||||
cueLine,
|
||||
lineIndex,
|
||||
cueLineIndex,
|
||||
false,
|
||||
'overlay-generic',
|
||||
)}
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
if (overlay.text) {
|
||||
return (
|
||||
<span
|
||||
className={styles.overlayLine}
|
||||
dangerouslySetInnerHTML={{ __html: sanitize(overlay.text) }}
|
||||
key={`extra-overlay-${overlayIndex}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
.root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.top-row,
|
||||
.controls-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: var(--theme-spacing-md);
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.overlay-toggle-active {
|
||||
color: var(--theme-colors-primary) !important;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
display: flex;
|
||||
gap: var(--theme-spacing-md);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.layer-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -1,55 +1,85 @@
|
||||
import isElectron from 'is-electron';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import styles from './lyrics-actions.module.css';
|
||||
|
||||
import { openLyricSearchModal } from '/@/renderer/features/lyrics/components/lyrics-search-form';
|
||||
import { useLyricsSettings, usePlayerSong } from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { AppIcon } from '/@/shared/components/icon/icon';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Select } from '/@/shared/components/select/select';
|
||||
import { Popover } from '/@/shared/components/popover/popover';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Switch } from '/@/shared/components/switch/switch';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||
import { LyricsOverride } from '/@/shared/types/domain-types';
|
||||
import { LyricsKind, LyricsOverride } from '/@/shared/types/domain-types';
|
||||
|
||||
export type OverlayLayerToggle = {
|
||||
key: string;
|
||||
kind: LyricsKind;
|
||||
label: string;
|
||||
};
|
||||
|
||||
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;
|
||||
onToggleOverlayLayer?: (key: string) => void;
|
||||
onTranslateLyric?: () => void;
|
||||
onUpdateOffset: (offsetMs: number) => void;
|
||||
overlayLayers?: OverlayLayerToggle[];
|
||||
setIndex: (idx: number) => void;
|
||||
settingsKey?: string;
|
||||
showPronunciationLayer?: boolean;
|
||||
showTranslationLayer?: boolean;
|
||||
synced?: boolean;
|
||||
visibleOverlayKeys?: Set<string>;
|
||||
}
|
||||
|
||||
const OVERLAY_KIND_ICONS: Partial<Record<LyricsKind, keyof typeof AppIcon>> = {
|
||||
pronunciation: 'audioLines',
|
||||
translation: 'languages',
|
||||
};
|
||||
|
||||
const getOverlayTooltip = (
|
||||
layer: OverlayLayerToggle,
|
||||
t: (key: string) => string,
|
||||
isActive: boolean,
|
||||
): string => {
|
||||
const action = isActive ? 'Hide' : 'Show';
|
||||
|
||||
if (layer.kind === 'pronunciation') {
|
||||
return `${action} ${t('page.fullscreenPlayer.showPronunciation').toLowerCase()}`;
|
||||
}
|
||||
|
||||
if (layer.kind === 'translation') {
|
||||
return `${action} ${t('page.fullscreenPlayer.showTranslation').toLowerCase()}`;
|
||||
}
|
||||
|
||||
return `${action} ${layer.label}`;
|
||||
};
|
||||
|
||||
export const LyricsActions = ({
|
||||
hasLyrics,
|
||||
hasPronunciationLayer = false,
|
||||
hasTranslationLayer = false,
|
||||
index,
|
||||
languages,
|
||||
offsetMs,
|
||||
onExportLyrics,
|
||||
onRemoveLyric,
|
||||
onSearchOverride,
|
||||
onTogglePronunciationLayer,
|
||||
onToggleTranslationLayer,
|
||||
onToggleOverlayLayer,
|
||||
onTranslateLyric,
|
||||
onUpdateOffset,
|
||||
overlayLayers = [],
|
||||
setIndex,
|
||||
showPronunciationLayer = false,
|
||||
showTranslationLayer = false,
|
||||
visibleOverlayKeys = new Set(),
|
||||
}: LyricsActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const currentSong = usePlayerSong();
|
||||
@@ -61,21 +91,149 @@ export const LyricsActions = ({
|
||||
|
||||
const isActionsDisabled = !currentSong;
|
||||
const isDesktop = isElectron();
|
||||
const hasServerTranslationLayer = overlayLayers.some((layer) => layer.kind === 'translation');
|
||||
const hasMultipleLanguages = languages.length > 1;
|
||||
|
||||
const selectedLanguage = useMemo(
|
||||
() => languages.find((language) => language.value === index.toString()),
|
||||
[index, languages],
|
||||
);
|
||||
|
||||
const { extraOverlayLayers, quickOverlayLayers } = useMemo(() => {
|
||||
const quick: OverlayLayerToggle[] = [];
|
||||
const extra: OverlayLayerToggle[] = [];
|
||||
|
||||
for (const layer of overlayLayers) {
|
||||
if (layer.kind === 'pronunciation' || layer.kind === 'translation') {
|
||||
quick.push(layer);
|
||||
continue;
|
||||
}
|
||||
|
||||
extra.push(layer);
|
||||
}
|
||||
|
||||
return {
|
||||
extraOverlayLayers: extra,
|
||||
quickOverlayLayers: quick,
|
||||
};
|
||||
}, [overlayLayers]);
|
||||
|
||||
const hasActiveExtraOverlay = extraOverlayLayers.some((layer) =>
|
||||
visibleOverlayKeys.has(layer.key),
|
||||
);
|
||||
|
||||
const languageTooltip = selectedLanguage
|
||||
? `${t('page.fullscreenPlayer.lyricLanguage')}: ${selectedLanguage.label}`
|
||||
: t('page.fullscreenPlayer.lyricLanguage');
|
||||
|
||||
const showTopRow =
|
||||
hasLyrics ||
|
||||
hasMultipleLanguages ||
|
||||
quickOverlayLayers.length > 0 ||
|
||||
extraOverlayLayers.length > 0;
|
||||
|
||||
const languageMenu = hasMultipleLanguages ? (
|
||||
<DropdownMenu position="top">
|
||||
<DropdownMenu.Target>
|
||||
<ActionIcon
|
||||
aria-label={languageTooltip}
|
||||
disabled={isActionsDisabled}
|
||||
icon="metadata"
|
||||
iconProps={{ size: 'lg' }}
|
||||
size="sm"
|
||||
tooltip={{
|
||||
label: languageTooltip,
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{languages.map((language) => (
|
||||
<DropdownMenu.Item
|
||||
isSelected={language.value === index.toString()}
|
||||
key={language.value}
|
||||
onClick={() => setIndex(parseInt(language.value, 10))}
|
||||
>
|
||||
{language.label}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
) : null;
|
||||
|
||||
const overlayToggleIcons = quickOverlayLayers.map((layer) => {
|
||||
const isActive = visibleOverlayKeys.has(layer.key);
|
||||
const icon = OVERLAY_KIND_ICONS[layer.kind] ?? 'list';
|
||||
|
||||
return onToggleOverlayLayer ? (
|
||||
<ActionIcon
|
||||
aria-label={getOverlayTooltip(layer, t, isActive)}
|
||||
className={isActive ? styles.overlayToggleActive : undefined}
|
||||
disabled={isActionsDisabled}
|
||||
icon={icon}
|
||||
iconProps={isActive ? { color: 'primary', size: 'lg' } : { size: 'lg' }}
|
||||
key={layer.key}
|
||||
onClick={() => onToggleOverlayLayer(layer.key)}
|
||||
size="sm"
|
||||
tooltip={{
|
||||
label: getOverlayTooltip(layer, t, isActive),
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
) : null;
|
||||
});
|
||||
|
||||
const extraLayersPopover =
|
||||
extraOverlayLayers.length > 0 && onToggleOverlayLayer ? (
|
||||
<Popover position="top" withArrow>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
aria-label={t('page.fullscreenPlayer.lyricLayers')}
|
||||
className={hasActiveExtraOverlay ? styles.overlayToggleActive : undefined}
|
||||
disabled={isActionsDisabled}
|
||||
icon="list"
|
||||
iconProps={
|
||||
hasActiveExtraOverlay
|
||||
? { color: 'primary', size: 'lg' }
|
||||
: { size: 'lg' }
|
||||
}
|
||||
size="sm"
|
||||
tooltip={{
|
||||
label: t('page.fullscreenPlayer.lyricLayers'),
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown maw={280} miw={220} onClick={(e) => e.stopPropagation()} p="sm">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} isNoSelect size="sm">
|
||||
{t('page.fullscreenPlayer.lyricLayers')}
|
||||
</Text>
|
||||
{extraOverlayLayers.map((layer) => (
|
||||
<div className={styles.layerRow} key={layer.key}>
|
||||
<Text className={styles.layerLabel} isNoSelect size="sm">
|
||||
{layer.label}
|
||||
</Text>
|
||||
<Switch
|
||||
aria-label={layer.label}
|
||||
checked={visibleOverlayKeys.has(layer.key)}
|
||||
onChange={() => onToggleOverlayLayer(layer.key)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
{hasLyrics && (
|
||||
<Center pb="md">
|
||||
{languages.length > 1 && (
|
||||
<Select
|
||||
clearable={false}
|
||||
data={languages}
|
||||
onChange={(value) => setIndex(parseInt(value!, 10))}
|
||||
style={{ bottom: 30, position: 'absolute' }}
|
||||
value={index.toString()}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.root}>
|
||||
{showTopRow ? (
|
||||
<Group className={styles.topRow} gap="xs" justify="center">
|
||||
{hasLyrics ? (
|
||||
<Button
|
||||
onClick={onExportLyrics}
|
||||
size="compact-sm"
|
||||
@@ -84,102 +242,82 @@ export const LyricsActions = ({
|
||||
>
|
||||
{t('form.lyricsExport.export')}
|
||||
</Button>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
<Group justify="center">
|
||||
{isDesktop && sources.length ? (
|
||||
<Button
|
||||
disabled={isActionsDisabled}
|
||||
onClick={() =>
|
||||
openLyricSearchModal({
|
||||
artist: currentSong?.artistName,
|
||||
name: currentSong?.name,
|
||||
onSearchOverride,
|
||||
})
|
||||
}
|
||||
uppercase
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.search')}
|
||||
</Button>
|
||||
) : null}
|
||||
<ActionIcon
|
||||
aria-label="Decrease lyric offset"
|
||||
icon="minus"
|
||||
onClick={() => handleLyricOffset(offsetMs - 50)}
|
||||
tooltip={{
|
||||
label: t('common.slower'),
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
<Tooltip label={t('setting.lyricOffset')} openDelay={0}>
|
||||
<NumberInput
|
||||
aria-label="Lyric offset"
|
||||
onChange={handleLyricOffset}
|
||||
styles={{ input: { textAlign: 'center' } }}
|
||||
value={offsetMs || 0}
|
||||
width={70}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
aria-label="Increase lyric offset"
|
||||
icon="plus"
|
||||
onClick={() => handleLyricOffset(offsetMs + 50)}
|
||||
tooltip={{
|
||||
label: t('common.faster'),
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
{isDesktop && sources.length ? (
|
||||
<Button
|
||||
disabled={isActionsDisabled}
|
||||
onClick={onRemoveLyric}
|
||||
uppercase
|
||||
variant="subtle"
|
||||
>
|
||||
{hasLyrics ? t('common.clear') : t('common.refresh')}
|
||||
</Button>
|
||||
) : null}
|
||||
{languageMenu}
|
||||
{overlayToggleIcons}
|
||||
{extraLayersPopover}
|
||||
</Group>
|
||||
|
||||
<div style={{ position: 'absolute', right: 0, top: -50 }}>
|
||||
<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>
|
||||
</>
|
||||
) : null}
|
||||
<Group className={styles.controlsRow} gap="xs" justify="center">
|
||||
{isDesktop && sources.length ? (
|
||||
<Button
|
||||
disabled={isActionsDisabled}
|
||||
onClick={() =>
|
||||
openLyricSearchModal({
|
||||
artist: currentSong?.artistName,
|
||||
name: currentSong?.name,
|
||||
onSearchOverride,
|
||||
})
|
||||
}
|
||||
uppercase
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.search')}
|
||||
</Button>
|
||||
) : null}
|
||||
<ActionIcon
|
||||
aria-label="Decrease lyric offset"
|
||||
disabled={isActionsDisabled}
|
||||
icon="minus"
|
||||
onClick={() => handleLyricOffset(offsetMs - 50)}
|
||||
tooltip={{
|
||||
label: t('common.slower'),
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
<Tooltip label={t('setting.lyricOffset')} openDelay={0}>
|
||||
<NumberInput
|
||||
aria-label="Lyric offset"
|
||||
disabled={isActionsDisabled}
|
||||
onChange={handleLyricOffset}
|
||||
styles={{ input: { textAlign: 'center' } }}
|
||||
value={offsetMs || 0}
|
||||
width={70}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
aria-label="Increase lyric offset"
|
||||
disabled={isActionsDisabled}
|
||||
icon="plus"
|
||||
onClick={() => handleLyricOffset(offsetMs + 50)}
|
||||
tooltip={{
|
||||
label: t('common.faster'),
|
||||
openDelay: 0,
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
{isDesktop && sources.length ? (
|
||||
<Button
|
||||
disabled={isActionsDisabled}
|
||||
onClick={onRemoveLyric}
|
||||
uppercase
|
||||
variant="subtle"
|
||||
>
|
||||
{hasLyrics ? t('common.clear') : t('common.refresh')}
|
||||
</Button>
|
||||
) : null}
|
||||
{isDesktop && sources.length && onTranslateLyric && !hasServerTranslationLayer ? (
|
||||
<Button
|
||||
disabled={isActionsDisabled}
|
||||
onClick={onTranslateLyric}
|
||||
uppercase
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.translation')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
formatStructuredLyricLabel,
|
||||
getLyricLineText,
|
||||
getLyricsLayers,
|
||||
getOverlayLayerKey,
|
||||
lyricsHasWordCues,
|
||||
} from '/@/renderer/features/lyrics/api/lyrics-utils';
|
||||
import { openLyricsExportModal } from '/@/renderer/features/lyrics/components/lyrics-export-form';
|
||||
@@ -72,8 +73,7 @@ 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 [visibleOverlayKeys, setVisibleOverlayKeys] = useState<Set<string>>(new Set());
|
||||
const [pendingSongId, setPendingSongId] = useState<string | undefined>(currentSong?.id);
|
||||
const lyricsFetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const previousSongIdRef = useRef<string | undefined>(currentSong?.id);
|
||||
@@ -167,21 +167,53 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
||||
return getLyricsLayers(data.local);
|
||||
}, [data]);
|
||||
|
||||
const translationLyricsOverlay = useMemo(() => {
|
||||
if (!showTranslationLayer || !layers?.translation?.synced) {
|
||||
return null;
|
||||
const overlayLayerToggles = useMemo(() => {
|
||||
if (!layers?.overlayLayers.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return layers.translation.lyrics;
|
||||
}, [layers, showTranslationLayer]);
|
||||
return layers.overlayLayers.map((layer) => ({
|
||||
key: getOverlayLayerKey(layer),
|
||||
kind: layer.synced ? (layer.kind ?? 'main') : 'main',
|
||||
label: formatStructuredLyricLabel(layer),
|
||||
}));
|
||||
}, [layers]);
|
||||
|
||||
const visibleOverlayLayers = useMemo(() => {
|
||||
if (!layers?.overlayLayers.length || !visibleOverlayKeys.size) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return layers.overlayLayers.filter((layer) =>
|
||||
visibleOverlayKeys.has(getOverlayLayerKey(layer)),
|
||||
);
|
||||
}, [layers, visibleOverlayKeys]);
|
||||
|
||||
const pronunciationLyricsOverlay = useMemo(() => {
|
||||
if (!showPronunciationLayer || !layers?.pronunciation?.synced) {
|
||||
return null;
|
||||
}
|
||||
const layer = visibleOverlayLayers.find(
|
||||
(entry) => entry.synced && entry.kind === 'pronunciation',
|
||||
);
|
||||
|
||||
return layers.pronunciation.lyrics;
|
||||
}, [layers, showPronunciationLayer]);
|
||||
return layer?.synced ? layer.lyrics : null;
|
||||
}, [visibleOverlayLayers]);
|
||||
|
||||
const translationLyricsOverlay = useMemo(() => {
|
||||
const layer = visibleOverlayLayers.find(
|
||||
(entry) => entry.synced && entry.kind === 'translation',
|
||||
);
|
||||
|
||||
return layer?.synced ? layer.lyrics : null;
|
||||
}, [visibleOverlayLayers]);
|
||||
|
||||
const extraOverlayLyrics = useMemo(() => {
|
||||
return visibleOverlayLayers
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.synced && entry.kind !== 'pronunciation' && entry.kind !== 'translation',
|
||||
)
|
||||
.map((entry) => (entry.synced ? entry.lyrics : null))
|
||||
.filter((entry): entry is NonNullable<typeof entry> => entry != null);
|
||||
}, [visibleOverlayLayers]);
|
||||
|
||||
const selectedAgents = useMemo(() => {
|
||||
if (!lyrics || !('synced' in lyrics) || !lyrics.synced) {
|
||||
@@ -217,6 +249,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
||||
|
||||
return {
|
||||
...(displayLyrics as SynchronizedLyricsProps),
|
||||
extraOverlayLyrics: isKaraoke ? extraOverlayLyrics : undefined,
|
||||
offsetMs: displayOffsetMs,
|
||||
pronunciationLyrics: pronunciationLyricsOverlay,
|
||||
romajiLyrics:
|
||||
@@ -226,19 +259,21 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
||||
settingsKey,
|
||||
syncedRomajiLyrics:
|
||||
enableRomaji && !useServerPronunciation ? (syncedRomajiLyrics ?? null) : null,
|
||||
translatedLyrics: showTranslation && !showTranslationLayer ? translatedLyrics : null,
|
||||
translatedLyrics:
|
||||
showTranslation && !translationLyricsOverlay ? translatedLyrics : null,
|
||||
translationLyrics: translationLyricsOverlay,
|
||||
};
|
||||
}, [
|
||||
displayLyrics,
|
||||
displayOffsetMs,
|
||||
enableRomaji,
|
||||
extraOverlayLyrics,
|
||||
isKaraoke,
|
||||
pronunciationLyricsOverlay,
|
||||
romajiConvertedLyrics,
|
||||
syncedRomajiLyrics,
|
||||
settingsKey,
|
||||
showTranslation,
|
||||
showTranslationLayer,
|
||||
translatedLyrics,
|
||||
translationLyricsOverlay,
|
||||
useServerPronunciation,
|
||||
@@ -349,13 +384,24 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
|
||||
await fetchTranslation();
|
||||
}, [translatedLyrics, showTranslation, fetchTranslation]);
|
||||
|
||||
const handleToggleOverlayLayer = useCallback((key: string) => {
|
||||
setVisibleOverlayKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
usePlayerEvents(
|
||||
{
|
||||
onCurrentSongChange: () => {
|
||||
setIndexState(0);
|
||||
setShowTranslation(false);
|
||||
setShowTranslationLayer(false);
|
||||
setShowPronunciationLayer(false);
|
||||
setVisibleOverlayKeys(new Set());
|
||||
setTranslatedLyrics(null);
|
||||
},
|
||||
},
|
||||
@@ -483,30 +529,23 @@ 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)
|
||||
}
|
||||
onToggleOverlayLayer={handleToggleOverlayLayer}
|
||||
onTranslateLyric={
|
||||
translationApiProvider && translationApiKey
|
||||
? handleOnTranslateLyric
|
||||
: undefined
|
||||
}
|
||||
onUpdateOffset={handleUpdateOffset}
|
||||
overlayLayers={overlayLayerToggles}
|
||||
setIndex={setIndex}
|
||||
settingsKey={settingsKey}
|
||||
showPronunciationLayer={showPronunciationLayer}
|
||||
showTranslationLayer={showTranslationLayer}
|
||||
visibleOverlayKeys={visibleOverlayKeys}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,11 +56,26 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.karaoke-word.karaoke-romaji-word {
|
||||
.karaoke-word.karaoke-overlay-word {
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.karaoke-word.karaoke-overlay-pronunciation {
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.karaoke-word.karaoke-overlay-translation {
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.karaoke-word.karaoke-overlay-generic {
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.karaoke-word-text,
|
||||
.karaoke-word-highlight {
|
||||
grid-area: 1 / 1;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
findOverlayLineByTime,
|
||||
getLyricLineStartMs,
|
||||
getLyricLineText,
|
||||
getOverlayCueLinesForLine,
|
||||
normalizeLyrics,
|
||||
} from '/@/renderer/features/lyrics/api/lyrics-utils';
|
||||
import { LyricsScrollContent } from '/@/renderer/features/lyrics/components/lyrics-scroll-content';
|
||||
@@ -30,6 +31,7 @@ import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export interface SynchronizedKaraokeLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
|
||||
agents?: LyricAgent[];
|
||||
extraOverlayLyrics?: SynchronizedLyricsData[];
|
||||
lyrics: SynchronizedLyricsData;
|
||||
offsetMs?: number;
|
||||
pronunciationLyrics?: null | SynchronizedLyricsData;
|
||||
@@ -46,6 +48,7 @@ const SEEK_DETECT_THRESHOLD_MS = 500;
|
||||
export const SynchronizedKaraokeLyrics = ({
|
||||
agents,
|
||||
artist,
|
||||
extraOverlayLyrics,
|
||||
lyrics,
|
||||
name,
|
||||
offsetMs,
|
||||
@@ -278,10 +281,11 @@ export const SynchronizedKaraokeLyrics = ({
|
||||
const getOverlayText = (
|
||||
overlayLyrics: null | SynchronizedLyricsData | undefined,
|
||||
startMs: number,
|
||||
lineIndex: number,
|
||||
fallback?: null | string,
|
||||
) => {
|
||||
if (overlayLyrics) {
|
||||
return findOverlayLineByTime(overlayLyrics, startMs);
|
||||
return findOverlayLineByTime(overlayLyrics, startMs, lineIndex);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
@@ -320,16 +324,32 @@ export const SynchronizedKaraokeLyrics = ({
|
||||
)}
|
||||
{normalizedLyrics.map((rawLine, idx) => {
|
||||
const lineStartMs = getLyricLineStartMs(rawLine);
|
||||
const pronunciationCueLines = getOverlayCueLinesForLine(
|
||||
pronunciationLyrics,
|
||||
lineStartMs,
|
||||
idx,
|
||||
);
|
||||
const translationCueLines = getOverlayCueLinesForLine(
|
||||
translationLyrics,
|
||||
lineStartMs,
|
||||
idx,
|
||||
);
|
||||
const pronunciationText = getOverlayText(
|
||||
pronunciationLyrics,
|
||||
lineStartMs,
|
||||
idx,
|
||||
romajiLyrics?.[idx] ? getLyricLineText(romajiLyrics[idx]) : undefined,
|
||||
);
|
||||
const translationText = getOverlayText(
|
||||
translationLyrics,
|
||||
lineStartMs,
|
||||
idx,
|
||||
translatedLyrics?.split('\n')[idx],
|
||||
);
|
||||
const extraOverlays = extraOverlayLyrics?.map((overlayLyrics) => ({
|
||||
cueLines: getOverlayCueLinesForLine(overlayLyrics, lineStartMs, idx),
|
||||
text: getOverlayText(overlayLyrics, lineStartMs, idx),
|
||||
}));
|
||||
|
||||
if (!rawLine.cueLines?.length) {
|
||||
return (
|
||||
@@ -354,13 +374,16 @@ export const SynchronizedKaraokeLyrics = ({
|
||||
className="synchronized"
|
||||
cueLines={rawLine.cueLines}
|
||||
data-lyric-time={lineStartMs}
|
||||
extraOverlays={extraOverlays}
|
||||
fontSize={settings.fontSize}
|
||||
id={`karaoke-line-${idx}`}
|
||||
key={idx}
|
||||
lineIndex={idx}
|
||||
pronunciationCueLines={pronunciationCueLines}
|
||||
pronunciationText={pronunciationText}
|
||||
romajiCueLines={syncedRomajiLyrics?.[idx] ?? null}
|
||||
romajiText={pronunciationText}
|
||||
translatedText={translationText}
|
||||
translationCueLines={translationCueLines}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
export interface SynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
|
||||
extraOverlayLyrics?: SynchronizedLyricsData[];
|
||||
lyrics: SynchronizedLyricsData;
|
||||
offsetMs?: number;
|
||||
pronunciationLyrics?: null | SynchronizedLyricsData;
|
||||
@@ -223,10 +224,11 @@ export const SynchronizedLyrics = ({
|
||||
const getOverlayText = (
|
||||
overlayLyrics: null | SynchronizedLyricsData | undefined,
|
||||
startMs: number,
|
||||
lineIndex: number,
|
||||
fallback?: null | string,
|
||||
) => {
|
||||
if (overlayLyrics) {
|
||||
return findOverlayLineByTime(overlayLyrics, startMs);
|
||||
return findOverlayLineByTime(overlayLyrics, startMs, lineIndex);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
@@ -269,11 +271,13 @@ export const SynchronizedLyrics = ({
|
||||
const pronunciationText = getOverlayText(
|
||||
pronunciationLyrics,
|
||||
lineStartMs,
|
||||
idx,
|
||||
romajiLyrics?.[idx] ? getLyricLineText(romajiLyrics[idx]) : undefined,
|
||||
);
|
||||
const translationText = getOverlayText(
|
||||
translationLyrics,
|
||||
lineStartMs,
|
||||
idx,
|
||||
translatedLyrics?.split('\n')[idx],
|
||||
);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
LuArrowUpDown,
|
||||
LuArrowUpNarrowWide,
|
||||
LuArrowUpToLine,
|
||||
LuAudioLines,
|
||||
LuBookOpen,
|
||||
LuBraces,
|
||||
LuCamera,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
LuInfinity,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuLanguages,
|
||||
LuLayoutGrid,
|
||||
LuLayoutList,
|
||||
LuLibrary,
|
||||
@@ -243,6 +245,7 @@ export const AppIcon = {
|
||||
arrowUpS: LuChevronUp,
|
||||
arrowUpToLine: LuArrowUpToLine,
|
||||
artist: LuUserPen,
|
||||
audioLines: LuAudioLines,
|
||||
brandGitHub: LuGithub,
|
||||
brandLastfm: LastfmLogoIcon,
|
||||
brandListenBrainz: ListenBrainzLogoIcon,
|
||||
@@ -290,6 +293,7 @@ export const AppIcon = {
|
||||
itemSong: LuMusic,
|
||||
json: LuBraces,
|
||||
keyboard: LuKeyboard,
|
||||
languages: LuLanguages,
|
||||
lastPlayed: LuHeadphones,
|
||||
layoutDetail: LuLayoutList,
|
||||
layoutGrid: LuLayoutGrid,
|
||||
|
||||
Reference in New Issue
Block a user