add setting for lyrics follow alignment

This commit is contained in:
jeffvli
2026-07-09 01:31:04 -07:00
parent bdcc88cbfe
commit df37123382
12 changed files with 99 additions and 4 deletions
+13
View File
@@ -100,6 +100,8 @@ These variables override app settings **on first run** when no persisted setting
| `lyrics.fetch` | `true` | `FS_LYRICS_FETCH` | `true` / `false` — Fetch lyrics. | | `lyrics.fetch` | `true` | `FS_LYRICS_FETCH` | `true` / `false` — Fetch lyrics. |
| `lyrics.follow` | `true` | `FS_LYRICS_FOLLOW` | `true` / `false` — Follow current line. | | `lyrics.follow` | `true` | `FS_LYRICS_FOLLOW` | `true` / `false` — Follow current line. |
| `lyrics.delayMs` | `0` | `FS_LYRICS_DELAY_MS` | Sync delay in milliseconds. | | `lyrics.delayMs` | `0` | `FS_LYRICS_DELAY_MS` | Sync delay in milliseconds. |
| `lyrics.lineLeadTimeMs` | `800` | `FS_LYRICS_LINE_LEAD_TIME_MS` | Milliseconds before a line becomes active while following. |
| `lyrics.followScrollAlignment` | `0` | `FS_LYRICS_FOLLOW_SCROLL_ALIGNMENT` | Vertical follow position. `0` centers the active line; negative moves it higher, positive moves it lower (`-50` to `50`). |
| `lyrics.preferLocalLyrics` | `true` | `FS_LYRICS_PREFER_LOCAL` | `true` / `false` — Prefer local lyric files. | | `lyrics.preferLocalLyrics` | `true` | `FS_LYRICS_PREFER_LOCAL` | `true` / `false` — Prefer local lyric files. |
| `lyrics.showMatch` | `true` | `FS_LYRICS_SHOW_MATCH` | `true` / `false`. | | `lyrics.showMatch` | `true` | `FS_LYRICS_SHOW_MATCH` | `true` / `false`. |
| `lyrics.showProvider` | `true` | `FS_LYRICS_SHOW_PROVIDER` | `true` / `false`. | | `lyrics.showProvider` | `true` | `FS_LYRICS_SHOW_PROVIDER` | `true` / `false`. |
@@ -110,6 +112,17 @@ These variables override app settings **on first run** when no persisted setting
--- ---
## Lyrics display
Applies to the default lyrics display profile (`lyricsDisplay.default`).
| Setting path | Default | Env variable | Available values / Description |
|-------------|---------|--------------|--------------------------------|
| `lyricsDisplay.default.paddingLeft` | `0` | `FS_LYRICS_PADDING_LEFT` | Left content padding as a percentage (`0``20`). |
| `lyricsDisplay.default.paddingRight` | `0` | `FS_LYRICS_PADDING_RIGHT` | Right content padding as a percentage (`0``20`). |
---
## Auto DJ ## Auto DJ
| Setting path | Default | Env variable | Available values / Description | | Setting path | Default | Env variable | Available values / Description |
+4
View File
@@ -80,6 +80,8 @@ window.FS_DISCORD_SHOW_STATE_ICON = "${FS_DISCORD_SHOW_STATE_ICON}";
window.FS_LYRICS_FETCH = "${FS_LYRICS_FETCH}"; window.FS_LYRICS_FETCH = "${FS_LYRICS_FETCH}";
window.FS_LYRICS_FOLLOW = "${FS_LYRICS_FOLLOW}"; window.FS_LYRICS_FOLLOW = "${FS_LYRICS_FOLLOW}";
window.FS_LYRICS_DELAY_MS = "${FS_LYRICS_DELAY_MS}"; window.FS_LYRICS_DELAY_MS = "${FS_LYRICS_DELAY_MS}";
window.FS_LYRICS_LINE_LEAD_TIME_MS = "${FS_LYRICS_LINE_LEAD_TIME_MS}";
window.FS_LYRICS_FOLLOW_SCROLL_ALIGNMENT = "${FS_LYRICS_FOLLOW_SCROLL_ALIGNMENT}";
window.FS_LYRICS_PREFER_LOCAL = "${FS_LYRICS_PREFER_LOCAL}"; window.FS_LYRICS_PREFER_LOCAL = "${FS_LYRICS_PREFER_LOCAL}";
window.FS_LYRICS_SHOW_MATCH = "${FS_LYRICS_SHOW_MATCH}"; window.FS_LYRICS_SHOW_MATCH = "${FS_LYRICS_SHOW_MATCH}";
window.FS_LYRICS_SHOW_PROVIDER = "${FS_LYRICS_SHOW_PROVIDER}"; window.FS_LYRICS_SHOW_PROVIDER = "${FS_LYRICS_SHOW_PROVIDER}";
@@ -87,6 +89,8 @@ window.FS_LYRICS_ENABLE_AUTO_TRANSLATION = "${FS_LYRICS_ENABLE_AUTO_TRANSLATION}
window.FS_LYRICS_TRANSLATION_API_KEY = "${FS_LYRICS_TRANSLATION_API_KEY}"; window.FS_LYRICS_TRANSLATION_API_KEY = "${FS_LYRICS_TRANSLATION_API_KEY}";
window.FS_LYRICS_TRANSLATION_TARGET_LANGUAGE = "${FS_LYRICS_TRANSLATION_TARGET_LANGUAGE}"; window.FS_LYRICS_TRANSLATION_TARGET_LANGUAGE = "${FS_LYRICS_TRANSLATION_TARGET_LANGUAGE}";
window.FS_LYRICS_ALIGNMENT = "${FS_LYRICS_ALIGNMENT}"; window.FS_LYRICS_ALIGNMENT = "${FS_LYRICS_ALIGNMENT}";
window.FS_LYRICS_PADDING_LEFT = "${FS_LYRICS_PADDING_LEFT}";
window.FS_LYRICS_PADDING_RIGHT = "${FS_LYRICS_PADDING_RIGHT}";
window.FS_AUTO_DJ_ALBUM_STRATEGY = "${FS_AUTO_DJ_ALBUM_STRATEGY}"; window.FS_AUTO_DJ_ALBUM_STRATEGY = "${FS_AUTO_DJ_ALBUM_STRATEGY}";
window.FS_AUTO_DJ_ENABLED = "${FS_AUTO_DJ_ENABLED}"; window.FS_AUTO_DJ_ENABLED = "${FS_AUTO_DJ_ENABLED}";
+1
View File
@@ -548,6 +548,7 @@
"dynamicImageBlur": "Image blur size", "dynamicImageBlur": "Image blur size",
"dynamicIsImage": "Enable background image", "dynamicIsImage": "Enable background image",
"followCurrentLyric": "Follow current lyric", "followCurrentLyric": "Follow current lyric",
"lyricFollowScrollAlignment": "Lyrics follow alignment",
"lyricAlignment": "Lyric alignment", "lyricAlignment": "Lyric alignment",
"lyricOffset": "Lyrics offset (ms)", "lyricOffset": "Lyrics offset (ms)",
"lyricGap": "Lyric gap", "lyricGap": "Lyric gap",
@@ -212,6 +212,23 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
description: '', description: '',
title: t('page.fullscreenPlayer.config.followCurrentLyric'), title: t('page.fullscreenPlayer.config.followCurrentLyric'),
}, },
{
control: (
<Slider
defaultValue={lyricsSettings.followScrollAlignment ?? 0}
label={(value) => value.toString()}
max={50}
min={-50}
onChangeEnd={(value) => {
updateLyricsSetting({ followScrollAlignment: value });
}}
step={1}
w={100}
/>
),
description: '',
title: t('page.fullscreenPlayer.config.lyricFollowScrollAlignment'),
},
{ {
control: ( control: (
<NumberInput <NumberInput
@@ -17,9 +17,9 @@ const LINE_ANIMATING_CLASS = 'lyrics-line-animating';
const LINE_PRE_ANIMATING_CLASS = 'lyrics-line-pre-animating'; const LINE_PRE_ANIMATING_CLASS = 'lyrics-line-pre-animating';
const TIME_JUMP_THRESHOLD = 0.5; const TIME_JUMP_THRESHOLD = 0.5;
const CENTER_SCROLL_POS_RATIO = 0.5;
const DEFAULT_ENDING_THRESHOLD = 0.5; const DEFAULT_ENDING_THRESHOLD = 0.5;
const DEFAULT_LINE_LEAD_TIME_MS = 800; const DEFAULT_LINE_LEAD_TIME_MS = 800;
const DEFAULT_SCROLL_POS_RATIO = 0.37;
const RICHSYNC_TIMING_OFFSET_MS = 150; const RICHSYNC_TIMING_OFFSET_MS = 150;
const SYNC_TIMING_OFFSET_MS = 115; const SYNC_TIMING_OFFSET_MS = 115;
const GLOW_DURATION_MULTIPLIER = 1.6; const GLOW_DURATION_MULTIPLIER = 1.6;
@@ -90,6 +90,7 @@ export interface TickOptions {
currentTimeMs: number; currentTimeMs: number;
eventCreationTime: number; eventCreationTime: number;
follow?: boolean; follow?: boolean;
followScrollAlignment?: number;
forceResync?: boolean; forceResync?: boolean;
isPlaying: boolean; isPlaying: boolean;
lineLeadTimeMs?: number; lineLeadTimeMs?: number;
@@ -99,6 +100,10 @@ export interface TickOptions {
smoothScroll?: boolean; smoothScroll?: boolean;
} }
export const getFollowScrollPositionRatio = (followScrollAlignment = 0): number => {
return Math.min(0.95, Math.max(0.05, CENTER_SCROLL_POS_RATIO + followScrollAlignment / 100));
};
const reflow = (element: HTMLElement): void => { const reflow = (element: HTMLElement): void => {
void element.offsetHeight; void element.offsetHeight;
}; };
@@ -735,7 +740,8 @@ export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions):
state.lastActiveElements = activeElems.filter((entry) => playbackTimeSec >= entry.time); state.lastActiveElements = activeElems.filter((entry) => playbackTimeSec >= entry.time);
if (activeElems.length > 0) { if (activeElems.length > 0) {
const scrollPosOffset = scrollHeight * DEFAULT_SCROLL_POS_RATIO; const scrollPosRatio = getFollowScrollPositionRatio(opts.followScrollAlignment);
const scrollPosOffset = scrollHeight * scrollPosRatio;
const lastActive = activeElems[activeElems.length - 1]; const lastActive = activeElems[activeElems.length - 1];
const useLastActiveOnly = newLyricSelected || activeElems.length > 1; const useLastActiveOnly = newLyricSelected || activeElems.length > 1;
const scrollLines = useLastActiveOnly ? [lastActive] : activeElems; const scrollLines = useLastActiveOnly ? [lastActive] : activeElems;
@@ -20,6 +20,7 @@ export interface UseLyricsAnimationEngineOptions {
containerRef: React.RefObject<HTMLElement | null>; containerRef: React.RefObject<HTMLElement | null>;
enabled?: boolean; enabled?: boolean;
followRef?: React.RefObject<boolean>; followRef?: React.RefObject<boolean>;
followScrollAlignmentRef?: React.RefObject<number>;
lineIdPrefix: 'karaoke-line' | 'lyric'; lineIdPrefix: 'karaoke-line' | 'lyric';
lineLeadTimeMsRef?: React.RefObject<number>; lineLeadTimeMsRef?: React.RefObject<number>;
lyrics: SynchronizedLyrics; lyrics: SynchronizedLyrics;
@@ -32,6 +33,7 @@ export const useLyricsAnimationEngine = ({
containerRef, containerRef,
enabled = true, enabled = true,
followRef, followRef,
followScrollAlignmentRef,
lineIdPrefix, lineIdPrefix,
lineLeadTimeMsRef, lineLeadTimeMsRef,
lyrics, lyrics,
@@ -116,6 +118,7 @@ export const useLyricsAnimationEngine = ({
currentTimeMs, currentTimeMs,
eventCreationTime: options?.eventCreationTime ?? Date.now(), eventCreationTime: options?.eventCreationTime ?? Date.now(),
follow: followRef?.current ?? true, follow: followRef?.current ?? true,
followScrollAlignment: followScrollAlignmentRef?.current ?? 0,
forceResync: options?.forceResync ?? false, forceResync: options?.forceResync ?? false,
isPlaying, isPlaying,
lineLeadTimeMs: lineLeadTimeMsRef?.current, lineLeadTimeMs: lineLeadTimeMsRef?.current,
@@ -51,6 +51,7 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu
const effectiveOffsetMs = offsetMs ?? 0; const effectiveOffsetMs = offsetMs ?? 0;
const delayMsRef = useRef(effectiveOffsetMs); const delayMsRef = useRef(effectiveOffsetMs);
const followRef = useRef(settings.follow); const followRef = useRef(settings.follow);
const followScrollAlignmentRef = useRef(settings.followScrollAlignment);
const lineLeadTimeMsRef = useRef(settings.lineLeadTimeMs); const lineLeadTimeMsRef = useRef(settings.lineLeadTimeMs);
const userScrollingRef = useRef(false); const userScrollingRef = useRef(false);
const scrollTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null); const scrollTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);
@@ -155,6 +156,10 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu
followRef.current = settings.follow; followRef.current = settings.follow;
}, [settings.follow]); }, [settings.follow]);
useEffect(() => {
followScrollAlignmentRef.current = settings.followScrollAlignment;
}, [settings.followScrollAlignment]);
useEffect(() => { useEffect(() => {
lineLeadTimeMsRef.current = settings.lineLeadTimeMs; lineLeadTimeMsRef.current = settings.lineLeadTimeMs;
}, [settings.lineLeadTimeMs]); }, [settings.lineLeadTimeMs]);
@@ -224,6 +229,7 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu
containerStyle, containerStyle,
delayMsRef, delayMsRef,
followRef, followRef,
followScrollAlignmentRef,
handleLineClick, handleLineClick,
handleSeek, handleSeek,
hideScrollbar, hideScrollbar,
@@ -63,6 +63,7 @@ export const SynchronizedKaraokeLyrics = ({
containerStyle, containerStyle,
delayMsRef, delayMsRef,
followRef, followRef,
followScrollAlignmentRef,
handleSeek, handleSeek,
hideScrollbar, hideScrollbar,
lineLeadTimeMsRef, lineLeadTimeMsRef,
@@ -92,6 +93,7 @@ export const SynchronizedKaraokeLyrics = ({
animStateRef: scrollAnimStateRef, animStateRef: scrollAnimStateRef,
containerRef, containerRef,
followRef, followRef,
followScrollAlignmentRef,
lineIdPrefix: 'karaoke-line', lineIdPrefix: 'karaoke-line',
lineLeadTimeMsRef, lineLeadTimeMsRef,
lyrics: normalizedLyrics, lyrics: normalizedLyrics,
@@ -56,6 +56,7 @@ export const SynchronizedLyrics = ({
containerStyle, containerStyle,
delayMsRef, delayMsRef,
followRef, followRef,
followScrollAlignmentRef,
handleLineClick, handleLineClick,
hideScrollbar, hideScrollbar,
lineLeadTimeMsRef, lineLeadTimeMsRef,
@@ -80,6 +81,7 @@ export const SynchronizedLyrics = ({
animStateRef: scrollAnimStateRef, animStateRef: scrollAnimStateRef,
containerRef, containerRef,
followRef, followRef,
followScrollAlignmentRef,
lineIdPrefix: 'lyric', lineIdPrefix: 'lyric',
lineLeadTimeMsRef, lineLeadTimeMsRef,
lyrics: normalizedLyrics, lyrics: normalizedLyrics,
+3
View File
@@ -68,7 +68,10 @@ declare global {
FS_LYRICS_ENABLE_AUTO_TRANSLATION?: string; FS_LYRICS_ENABLE_AUTO_TRANSLATION?: string;
FS_LYRICS_FETCH?: string; FS_LYRICS_FETCH?: string;
FS_LYRICS_FOLLOW?: string; FS_LYRICS_FOLLOW?: string;
FS_LYRICS_FOLLOW_SCROLL_ALIGNMENT?: string;
FS_LYRICS_LINE_LEAD_TIME_MS?: string; FS_LYRICS_LINE_LEAD_TIME_MS?: string;
FS_LYRICS_PADDING_LEFT?: string;
FS_LYRICS_PADDING_RIGHT?: string;
FS_LYRICS_PREFER_LOCAL?: string; FS_LYRICS_PREFER_LOCAL?: string;
FS_LYRICS_SHOW_MATCH?: string; FS_LYRICS_SHOW_MATCH?: string;
FS_LYRICS_SHOW_PROVIDER?: string; FS_LYRICS_SHOW_PROVIDER?: string;
+31 -1
View File
@@ -115,7 +115,10 @@ const AUTO_DJ_MODES = new Set(['albums', 'songs']);
const AUTO_DJ_STRATEGIES = new Set(['library_random', 'similar']); const AUTO_DJ_STRATEGIES = new Set(['library_random', 'similar']);
export type EnvSettingsOverrides = DeepPartial< export type EnvSettingsOverrides = DeepPartial<
Pick<SettingsState, 'autoDJ' | 'css' | 'discord' | 'font' | 'general' | 'lyrics' | 'playback'> Pick<
SettingsState,
'autoDJ' | 'css' | 'discord' | 'font' | 'general' | 'lyrics' | 'lyricsDisplay' | 'playback'
>
>; >;
type DeepPartial<T> = { type DeepPartial<T> = {
@@ -404,6 +407,15 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [
{ key: 'FS_LYRICS_FOLLOW', path: ['lyrics', 'follow'], type: 'bool' }, { key: 'FS_LYRICS_FOLLOW', path: ['lyrics', 'follow'], type: 'bool' },
{ key: 'FS_LYRICS_DELAY_MS', path: ['lyrics', 'delayMs'], type: 'num' }, { key: 'FS_LYRICS_DELAY_MS', path: ['lyrics', 'delayMs'], type: 'num' },
{ key: 'FS_LYRICS_LINE_LEAD_TIME_MS', path: ['lyrics', 'lineLeadTimeMs'], type: 'num' }, { key: 'FS_LYRICS_LINE_LEAD_TIME_MS', path: ['lyrics', 'lineLeadTimeMs'], type: 'num' },
{
key: 'FS_LYRICS_FOLLOW_SCROLL_ALIGNMENT',
path: ['lyrics', 'followScrollAlignment'],
transform: (s) => {
const n = parseNum(s);
return n !== undefined ? Math.min(50, Math.max(-50, Math.round(n))) : undefined;
},
type: 'num',
},
{ key: 'FS_LYRICS_PREFER_LOCAL', path: ['lyrics', 'preferLocalLyrics'], type: 'bool' }, { key: 'FS_LYRICS_PREFER_LOCAL', path: ['lyrics', 'preferLocalLyrics'], type: 'bool' },
{ key: 'FS_LYRICS_SHOW_MATCH', path: ['lyrics', 'showMatch'], type: 'bool' }, { key: 'FS_LYRICS_SHOW_MATCH', path: ['lyrics', 'showMatch'], type: 'bool' },
{ key: 'FS_LYRICS_SHOW_PROVIDER', path: ['lyrics', 'showProvider'], type: 'bool' }, { key: 'FS_LYRICS_SHOW_PROVIDER', path: ['lyrics', 'showProvider'], type: 'bool' },
@@ -425,6 +437,24 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [
path: ['lyrics', 'alignment'], path: ['lyrics', 'alignment'],
type: 'enum', type: 'enum',
}, },
{
key: 'FS_LYRICS_PADDING_LEFT',
path: ['lyricsDisplay', 'default', 'paddingLeft'],
transform: (s) => {
const n = parseNum(s);
return n !== undefined ? Math.min(20, Math.max(0, Math.round(n))) : undefined;
},
type: 'num',
},
{
key: 'FS_LYRICS_PADDING_RIGHT',
path: ['lyricsDisplay', 'default', 'paddingRight'],
transform: (s) => {
const n = parseNum(s);
return n !== undefined ? Math.min(20, Math.max(0, Math.round(n))) : undefined;
},
type: 'num',
},
{ {
enumSet: AUTO_DJ_STRATEGIES, enumSet: AUTO_DJ_STRATEGIES,
key: 'FS_AUTO_DJ_ALBUM_STRATEGY', key: 'FS_AUTO_DJ_ALBUM_STRATEGY',
+9 -1
View File
@@ -584,6 +584,7 @@ const LyricsSettingsSchema = z.object({
enableRomaji: z.boolean().optional(), enableRomaji: z.boolean().optional(),
fetch: z.boolean(), fetch: z.boolean(),
follow: z.boolean(), follow: z.boolean(),
followScrollAlignment: z.number(),
lineLeadTimeMs: z.number(), lineLeadTimeMs: z.number(),
preferLocalLyrics: z.boolean(), preferLocalLyrics: z.boolean(),
showMatch: z.boolean(), showMatch: z.boolean(),
@@ -1858,6 +1859,7 @@ const initialState: SettingsState = {
enableRomaji: false, enableRomaji: false,
fetch: true, fetch: true,
follow: true, follow: true,
followScrollAlignment: 0,
lineLeadTimeMs: 800, lineLeadTimeMs: 800,
preferLocalLyrics: true, preferLocalLyrics: true,
showMatch: true, showMatch: true,
@@ -2558,10 +2560,16 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
} }
} }
if (version < 31) {
if (state.lyrics.followScrollAlignment === undefined) {
state.lyrics.followScrollAlignment = 0;
}
}
return persistedState; return persistedState;
}, },
name: 'store_settings', name: 'store_settings',
version: 30, version: 31,
}, },
), ),
); );