From df3712338221e1fd6527ae558523e7b611894110 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 9 Jul 2026 01:31:04 -0700 Subject: [PATCH] add setting for lyrics follow alignment --- docs/ENV_SETTINGS.md | 13 ++++++++ settings.js.template | 4 +++ src/i18n/locales/en.json | 1 + .../components/lyrics-settings-form.tsx | 17 ++++++++++ .../lyrics/hooks/lyrics-animation-engine.ts | 10 ++++-- .../hooks/use-lyrics-animation-engine.ts | 3 ++ .../hooks/use-synchronized-lyrics-base.ts | 6 ++++ .../lyrics/synchronized-karaoke-lyrics.tsx | 2 ++ .../features/lyrics/synchronized-lyrics.tsx | 2 ++ src/renderer/global.d.ts | 3 ++ src/renderer/store/env-settings-overrides.ts | 32 ++++++++++++++++++- src/renderer/store/settings.store.ts | 10 +++++- 12 files changed, 99 insertions(+), 4 deletions(-) diff --git a/docs/ENV_SETTINGS.md b/docs/ENV_SETTINGS.md index 3417cc76b..ea451a142 100644 --- a/docs/ENV_SETTINGS.md +++ b/docs/ENV_SETTINGS.md @@ -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.follow` | `true` | `FS_LYRICS_FOLLOW` | `true` / `false` — Follow current line. | | `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.showMatch` | `true` | `FS_LYRICS_SHOW_MATCH` | `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 | Setting path | Default | Env variable | Available values / Description | diff --git a/settings.js.template b/settings.js.template index a451e6d37..0414d71f5 100644 --- a/settings.js.template +++ b/settings.js.template @@ -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_FOLLOW = "${FS_LYRICS_FOLLOW}"; 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_SHOW_MATCH = "${FS_LYRICS_SHOW_MATCH}"; 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_TARGET_LANGUAGE = "${FS_LYRICS_TRANSLATION_TARGET_LANGUAGE}"; 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_ENABLED = "${FS_AUTO_DJ_ENABLED}"; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 58cb4359e..beb08d2d9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -548,6 +548,7 @@ "dynamicImageBlur": "Image blur size", "dynamicIsImage": "Enable background image", "followCurrentLyric": "Follow current lyric", + "lyricFollowScrollAlignment": "Lyrics follow alignment", "lyricAlignment": "Lyric alignment", "lyricOffset": "Lyrics offset (ms)", "lyricGap": "Lyric gap", diff --git a/src/renderer/features/lyrics/components/lyrics-settings-form.tsx b/src/renderer/features/lyrics/components/lyrics-settings-form.tsx index 741d920e2..d106a129a 100644 --- a/src/renderer/features/lyrics/components/lyrics-settings-form.tsx +++ b/src/renderer/features/lyrics/components/lyrics-settings-form.tsx @@ -212,6 +212,23 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) => description: '', title: t('page.fullscreenPlayer.config.followCurrentLyric'), }, + { + control: ( + value.toString()} + max={50} + min={-50} + onChangeEnd={(value) => { + updateLyricsSetting({ followScrollAlignment: value }); + }} + step={1} + w={100} + /> + ), + description: '', + title: t('page.fullscreenPlayer.config.lyricFollowScrollAlignment'), + }, { control: ( { + return Math.min(0.95, Math.max(0.05, CENTER_SCROLL_POS_RATIO + followScrollAlignment / 100)); +}; + const reflow = (element: HTMLElement): void => { void element.offsetHeight; }; @@ -735,7 +740,8 @@ export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions): state.lastActiveElements = activeElems.filter((entry) => playbackTimeSec >= entry.time); 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 useLastActiveOnly = newLyricSelected || activeElems.length > 1; const scrollLines = useLastActiveOnly ? [lastActive] : activeElems; diff --git a/src/renderer/features/lyrics/hooks/use-lyrics-animation-engine.ts b/src/renderer/features/lyrics/hooks/use-lyrics-animation-engine.ts index f79839b7c..073bd23dc 100644 --- a/src/renderer/features/lyrics/hooks/use-lyrics-animation-engine.ts +++ b/src/renderer/features/lyrics/hooks/use-lyrics-animation-engine.ts @@ -20,6 +20,7 @@ export interface UseLyricsAnimationEngineOptions { containerRef: React.RefObject; enabled?: boolean; followRef?: React.RefObject; + followScrollAlignmentRef?: React.RefObject; lineIdPrefix: 'karaoke-line' | 'lyric'; lineLeadTimeMsRef?: React.RefObject; lyrics: SynchronizedLyrics; @@ -32,6 +33,7 @@ export const useLyricsAnimationEngine = ({ containerRef, enabled = true, followRef, + followScrollAlignmentRef, lineIdPrefix, lineLeadTimeMsRef, lyrics, @@ -116,6 +118,7 @@ export const useLyricsAnimationEngine = ({ currentTimeMs, eventCreationTime: options?.eventCreationTime ?? Date.now(), follow: followRef?.current ?? true, + followScrollAlignment: followScrollAlignmentRef?.current ?? 0, forceResync: options?.forceResync ?? false, isPlaying, lineLeadTimeMs: lineLeadTimeMsRef?.current, diff --git a/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts b/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts index c4eeb5e79..b4fcf12fe 100644 --- a/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts +++ b/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts @@ -51,6 +51,7 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu const effectiveOffsetMs = offsetMs ?? 0; const delayMsRef = useRef(effectiveOffsetMs); const followRef = useRef(settings.follow); + const followScrollAlignmentRef = useRef(settings.followScrollAlignment); const lineLeadTimeMsRef = useRef(settings.lineLeadTimeMs); const userScrollingRef = useRef(false); const scrollTimeoutRef = useRef>(null); @@ -155,6 +156,10 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu followRef.current = settings.follow; }, [settings.follow]); + useEffect(() => { + followScrollAlignmentRef.current = settings.followScrollAlignment; + }, [settings.followScrollAlignment]); + useEffect(() => { lineLeadTimeMsRef.current = settings.lineLeadTimeMs; }, [settings.lineLeadTimeMs]); @@ -224,6 +229,7 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu containerStyle, delayMsRef, followRef, + followScrollAlignmentRef, handleLineClick, handleSeek, hideScrollbar, diff --git a/src/renderer/features/lyrics/synchronized-karaoke-lyrics.tsx b/src/renderer/features/lyrics/synchronized-karaoke-lyrics.tsx index 76e9fa47a..001db744b 100644 --- a/src/renderer/features/lyrics/synchronized-karaoke-lyrics.tsx +++ b/src/renderer/features/lyrics/synchronized-karaoke-lyrics.tsx @@ -63,6 +63,7 @@ export const SynchronizedKaraokeLyrics = ({ containerStyle, delayMsRef, followRef, + followScrollAlignmentRef, handleSeek, hideScrollbar, lineLeadTimeMsRef, @@ -92,6 +93,7 @@ export const SynchronizedKaraokeLyrics = ({ animStateRef: scrollAnimStateRef, containerRef, followRef, + followScrollAlignmentRef, lineIdPrefix: 'karaoke-line', lineLeadTimeMsRef, lyrics: normalizedLyrics, diff --git a/src/renderer/features/lyrics/synchronized-lyrics.tsx b/src/renderer/features/lyrics/synchronized-lyrics.tsx index 28e24cdde..58d851cab 100644 --- a/src/renderer/features/lyrics/synchronized-lyrics.tsx +++ b/src/renderer/features/lyrics/synchronized-lyrics.tsx @@ -56,6 +56,7 @@ export const SynchronizedLyrics = ({ containerStyle, delayMsRef, followRef, + followScrollAlignmentRef, handleLineClick, hideScrollbar, lineLeadTimeMsRef, @@ -80,6 +81,7 @@ export const SynchronizedLyrics = ({ animStateRef: scrollAnimStateRef, containerRef, followRef, + followScrollAlignmentRef, lineIdPrefix: 'lyric', lineLeadTimeMsRef, lyrics: normalizedLyrics, diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 75c08d54d..414b9151c 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -68,7 +68,10 @@ declare global { FS_LYRICS_ENABLE_AUTO_TRANSLATION?: string; FS_LYRICS_FETCH?: string; FS_LYRICS_FOLLOW?: string; + FS_LYRICS_FOLLOW_SCROLL_ALIGNMENT?: 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_SHOW_MATCH?: string; FS_LYRICS_SHOW_PROVIDER?: string; diff --git a/src/renderer/store/env-settings-overrides.ts b/src/renderer/store/env-settings-overrides.ts index e8acc1814..4887ddb5a 100644 --- a/src/renderer/store/env-settings-overrides.ts +++ b/src/renderer/store/env-settings-overrides.ts @@ -115,7 +115,10 @@ const AUTO_DJ_MODES = new Set(['albums', 'songs']); const AUTO_DJ_STRATEGIES = new Set(['library_random', 'similar']); export type EnvSettingsOverrides = DeepPartial< - Pick + Pick< + SettingsState, + 'autoDJ' | 'css' | 'discord' | 'font' | 'general' | 'lyrics' | 'lyricsDisplay' | 'playback' + > >; type DeepPartial = { @@ -404,6 +407,15 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [ { key: 'FS_LYRICS_FOLLOW', path: ['lyrics', 'follow'], type: 'bool' }, { key: 'FS_LYRICS_DELAY_MS', path: ['lyrics', 'delayMs'], type: 'num' }, { key: 'FS_LYRICS_LINE_LEAD_TIME_MS', path: ['lyrics', 'lineLeadTimeMs'], type: 'num' }, + { + key: 'FS_LYRICS_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_SHOW_MATCH', path: ['lyrics', 'showMatch'], type: 'bool' }, { key: 'FS_LYRICS_SHOW_PROVIDER', path: ['lyrics', 'showProvider'], type: 'bool' }, @@ -425,6 +437,24 @@ const ENV_SETTING_SPECS: EnvSettingSpec[] = [ path: ['lyrics', 'alignment'], 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, key: 'FS_AUTO_DJ_ALBUM_STRATEGY', diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 87a3d7992..101f9dc2e 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -584,6 +584,7 @@ const LyricsSettingsSchema = z.object({ enableRomaji: z.boolean().optional(), fetch: z.boolean(), follow: z.boolean(), + followScrollAlignment: z.number(), lineLeadTimeMs: z.number(), preferLocalLyrics: z.boolean(), showMatch: z.boolean(), @@ -1858,6 +1859,7 @@ const initialState: SettingsState = { enableRomaji: false, fetch: true, follow: true, + followScrollAlignment: 0, lineLeadTimeMs: 800, preferLocalLyrics: true, showMatch: true, @@ -2558,10 +2560,16 @@ export const useSettingsStore = createWithEqualityFn()( } } + if (version < 31) { + if (state.lyrics.followScrollAlignment === undefined) { + state.lyrics.followScrollAlignment = 0; + } + } + return persistedState; }, name: 'store_settings', - version: 30, + version: 31, }, ), );