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