Add additional lyrics customizability options (#146)

This commit is contained in:
jeffvli 2023-08-04 19:32:41 -07:00
parent 72b4a60c7b
commit fca135ce2b
7 changed files with 230 additions and 64 deletions

View file

@ -2,14 +2,9 @@ import type { ChangeEvent } from 'react';
import { MultiSelect } from '/@/renderer/components/select'; import { MultiSelect } from '/@/renderer/components/select';
import { Slider } from '/@/renderer/components/slider'; import { Slider } from '/@/renderer/components/slider';
import { Switch } from '/@/renderer/components/switch'; import { Switch } from '/@/renderer/components/switch';
import { import { useSettingsStoreActions, useSettingsStore } from '/@/renderer/store/settings.store';
useSettingsStoreActions,
useSettingsStore,
useLyricsSettings,
} from '/@/renderer/store/settings.store';
import { TableColumn, TableType } from '/@/renderer/types'; import { TableColumn, TableType } from '/@/renderer/types';
import { Option } from '/@/renderer/components/option'; import { Option } from '/@/renderer/components/option';
import { NumberInput } from '/@/renderer/components/input';
export const SONG_TABLE_COLUMNS = [ export const SONG_TABLE_COLUMNS = [
{ label: 'Row Index', value: TableColumn.ROW_INDEX }, { label: 'Row Index', value: TableColumn.ROW_INDEX },
@ -97,7 +92,6 @@ interface TableConfigDropdownProps {
export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => { export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
const { setSettings } = useSettingsStoreActions(); const { setSettings } = useSettingsStoreActions();
const tableConfig = useSettingsStore((state) => state.tables); const tableConfig = useSettingsStore((state) => state.tables);
const lyricConfig = useLyricsSettings();
const handleAddOrRemoveColumns = (values: TableColumn[]) => { const handleAddOrRemoveColumns = (values: TableColumn[]) => {
const existingColumns = tableConfig[type].columns; const existingColumns = tableConfig[type].columns;
@ -182,24 +176,6 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
}); });
}; };
const handleLyricFollow = (e: ChangeEvent<HTMLInputElement>) => {
setSettings({
lyrics: {
...useSettingsStore.getState().lyrics,
follow: e.currentTarget.checked,
},
});
};
const handleLyricOffset = (e: ChangeEvent<HTMLInputElement>) => {
setSettings({
lyrics: {
...useSettingsStore.getState().lyrics,
delayMs: Number(e.currentTarget.value),
},
});
};
return ( return (
<> <>
<Option> <Option>
@ -220,25 +196,6 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
/> />
</Option.Control> </Option.Control>
</Option> </Option>
<Option>
<Option.Label>Follow current lyrics</Option.Label>
<Option.Control>
<Switch
defaultChecked={lyricConfig.follow}
onChange={handleLyricFollow}
/>
</Option.Control>
</Option>
<Option>
<Option.Label>Lyric offset (ms)</Option.Label>
<Option.Control>
<NumberInput
defaultValue={lyricConfig.delayMs}
step={10}
onBlur={handleLyricOffset}
/>
</Option.Control>
</Option>
<Option> <Option>
<Option.Control> <Option.Control>
<Slider <Slider

View file

@ -0,0 +1,26 @@
.lyric-line {
color: var(--main-fg);
font-weight: 400;
font-size: 2.5vmax;
transform: scale(0.95);
opacity: 0.5;
.active {
font-weight: 800 !important;
transform: scale(1) !important;
opacity: 1;
}
.active.unsynchronized {
opacity: 0.8;
}
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
}
.lyric-line.active {
font-weight: 800;
transform: scale(1);
opacity: 1;
}

View file

@ -1,31 +1,41 @@
import { ComponentPropsWithoutRef } from 'react'; import { ComponentPropsWithoutRef } from 'react';
import { TextTitle } from '/@/renderer/components/text-title'; import { TextTitle } from '/@/renderer/components/text-title';
import { TitleProps } from '@mantine/core';
import styled from 'styled-components'; import styled from 'styled-components';
interface LyricLineProps extends ComponentPropsWithoutRef<'div'> { interface LyricLineProps extends ComponentPropsWithoutRef<'div'> {
alignment: 'left' | 'center' | 'right';
fontSize: number;
text: string; text: string;
} }
const StyledText = styled(TextTitle)` const StyledText = styled(TextTitle)<TitleProps & { $alignment: string; $fontSize: number }>`
color: var(--main-fg); color: var(--main-fg);
font-weight: 400; font-weight: 400;
font-size: 2.5vmax; text-align: ${(props) => props.$alignment};
transform: scale(0.95); font-size: ${(props) => props.$fontSize}px;
opacity: 0.5; opacity: 0.5;
&.active { &.active {
font-weight: 800; font-weight: 800;
transform: scale(1);
opacity: 1; opacity: 1;
} }
&.active.unsynchronized { &.unsynchronized {
opacity: 0.8; opacity: 1;
} }
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out; transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
`; `;
export const LyricLine = ({ text, ...props }: LyricLineProps) => { export const LyricLine = ({ text, alignment, fontSize, ...props }: LyricLineProps) => {
return <StyledText {...props}>{text}</StyledText>; return (
<StyledText
$alignment={alignment}
$fontSize={fontSize}
{...props}
>
{text}
</StyledText>
);
}; };

View file

@ -15,10 +15,10 @@ import styled from 'styled-components';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const SynchronizedLyricsContainer = styled.div` const SynchronizedLyricsContainer = styled.div<{ $gap: number }>`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: ${(props) => props.$gap || 5}px;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 10vh 0 6vh; padding: 10vh 0 6vh;
@ -146,7 +146,8 @@ export const SynchronizedLyrics = ({
'sychronized-lyrics-scroll-container', 'sychronized-lyrics-scroll-container',
) as HTMLElement; ) as HTMLElement;
const currentLyric = document.querySelector(`#lyric-${index}`) as HTMLElement; const currentLyric = document.querySelector(`#lyric-${index}`) as HTMLElement;
const offsetTop = currentLyric.offsetTop - doc.clientHeight / 2 ?? 0; // eslint-disable-next-line no-unsafe-optional-chaining
const offsetTop = currentLyric?.offsetTop - doc?.clientHeight / 2 ?? 0;
if (currentLyric === null) { if (currentLyric === null) {
lyricRef.current = undefined; lyricRef.current = undefined;
@ -295,27 +296,34 @@ export const SynchronizedLyrics = ({
return ( return (
<SynchronizedLyricsContainer <SynchronizedLyricsContainer
$gap={settings.gap}
className="synchronized-lyrics overlay-scrollbar" className="synchronized-lyrics overlay-scrollbar"
id="sychronized-lyrics-scroll-container" id="sychronized-lyrics-scroll-container"
onMouseEnter={showScrollbar} onMouseEnter={showScrollbar}
onMouseLeave={hideScrollbar} onMouseLeave={hideScrollbar}
> >
{source && ( {settings.showProvider && source && (
<LyricLine <LyricLine
alignment={settings.alignment}
className="lyric-credit" className="lyric-credit"
fontSize={settings.fontSize}
text={`Provided by ${source}`} text={`Provided by ${source}`}
/> />
)} )}
{remote && ( {settings.showMatch && remote && (
<LyricLine <LyricLine
alignment={settings.alignment}
className="lyric-credit" className="lyric-credit"
fontSize={settings.fontSize}
text={`"${name} by ${artist}"`} text={`"${name} by ${artist}"`}
/> />
)} )}
{lyrics.map(([, text], idx) => ( {lyrics.map(([, text], idx) => (
<LyricLine <LyricLine
key={idx} key={idx}
alignment={settings.alignment}
className="lyric-line synchronized" className="lyric-line synchronized"
fontSize={settings.fontSize}
id={`lyric-${idx}`} id={`lyric-${idx}`}
text={text} text={text}
/> />

View file

@ -2,15 +2,16 @@ import { useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
import { FullLyricsMetadata } from '/@/renderer/api/types'; import { FullLyricsMetadata } from '/@/renderer/api/types';
import { useLyricsSettings } from '/@/renderer/store';
interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> { interface UnsynchronizedLyricsProps extends Omit<FullLyricsMetadata, 'lyrics'> {
lyrics: string; lyrics: string;
} }
const UnsynchronizedLyricsContainer = styled.div` const UnsynchronizedLyricsContainer = styled.div<{ $gap: number }>`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: ${(props) => props.$gap || 5}px;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 10vh 0 6vh; padding: 10vh 0 6vh;
@ -37,28 +38,38 @@ export const UnsynchronizedLyrics = ({
remote, remote,
source, source,
}: UnsynchronizedLyricsProps) => { }: UnsynchronizedLyricsProps) => {
const settings = useLyricsSettings();
const lines = useMemo(() => { const lines = useMemo(() => {
return lyrics.split('\n'); return lyrics.split('\n');
}, [lyrics]); }, [lyrics]);
return ( return (
<UnsynchronizedLyricsContainer className="unsynchronized-lyrics"> <UnsynchronizedLyricsContainer
$gap={settings.gapUnsync}
className="unsynchronized-lyrics"
>
{source && ( {source && (
<LyricLine <LyricLine
alignment={settings.alignment}
className="lyric-credit" className="lyric-credit"
fontSize={settings.fontSizeUnsync}
text={`Provided by ${source}`} text={`Provided by ${source}`}
/> />
)} )}
{remote && ( {remote && (
<LyricLine <LyricLine
alignment={settings.alignment}
className="lyric-credit" className="lyric-credit"
fontSize={settings.fontSizeUnsync}
text={`"${name} by ${artist}"`} text={`"${name} by ${artist}"`}
/> />
)} )}
{lines.map((text, idx) => ( {lines.map((text, idx) => (
<LyricLine <LyricLine
key={idx} key={idx}
className="lyric-line" alignment={settings.alignment}
className="lyric-line unsynchronized"
fontSize={settings.fontSizeUnsync}
id={`lyric-${idx}`} id={`lyric-${idx}`}
text={text} text={text}
/> />

View file

@ -1,15 +1,26 @@
import { useLayoutEffect, useRef } from 'react'; import { useLayoutEffect, useRef } from 'react';
import { Group } from '@mantine/core'; import { Divider, Group } from '@mantine/core';
import { useHotkeys } from '@mantine/hooks'; import { useHotkeys } from '@mantine/hooks';
import { Variants, motion } from 'framer-motion'; import { Variants, motion } from 'framer-motion';
import { RiArrowDownSLine, RiSettings3Line } from 'react-icons/ri'; import { RiArrowDownSLine, RiSettings3Line } from 'react-icons/ri';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
import styled from 'styled-components'; import styled from 'styled-components';
import { Button, Option, Popover, Switch } from '/@/renderer/components'; import {
Button,
NumberInput,
Option,
Popover,
Select,
Slider,
Switch,
} from '/@/renderer/components';
import { import {
useCurrentSong, useCurrentSong,
useFullScreenPlayerStore, useFullScreenPlayerStore,
useFullScreenPlayerStoreActions, useFullScreenPlayerStoreActions,
useLyricsSettings,
useSettingsStore,
useSettingsStoreActions,
useWindowSettings, useWindowSettings,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { useFastAverageColor } from '../../../hooks/use-fast-average-color'; import { useFastAverageColor } from '../../../hooks/use-fast-average-color';
@ -61,11 +72,22 @@ const BackgroundImageOverlay = styled.div`
const Controls = () => { const Controls = () => {
const { dynamicBackground, expanded, useImageAspectRatio } = useFullScreenPlayerStore(); const { dynamicBackground, expanded, useImageAspectRatio } = useFullScreenPlayerStore();
const { setStore } = useFullScreenPlayerStoreActions(); const { setStore } = useFullScreenPlayerStoreActions();
const { setSettings } = useSettingsStoreActions();
const lyricConfig = useLyricsSettings();
const handleToggleFullScreenPlayer = () => { const handleToggleFullScreenPlayer = () => {
setStore({ expanded: !expanded }); setStore({ expanded: !expanded });
}; };
const handleLyricsSettings = (property: string, value: any) => {
setSettings({
lyrics: {
...useSettingsStore.getState().lyrics,
[property]: value,
},
});
};
useHotkeys([['Escape', handleToggleFullScreenPlayer]]); useHotkeys([['Escape', handleToggleFullScreenPlayer]]);
return ( return (
@ -116,7 +138,7 @@ const Controls = () => {
<Option.Label>Use image aspect ratio</Option.Label> <Option.Label>Use image aspect ratio</Option.Label>
<Option.Control> <Option.Control>
<Switch <Switch
defaultChecked={useImageAspectRatio} checked={useImageAspectRatio}
onChange={(e) => onChange={(e) =>
setStore({ setStore({
useImageAspectRatio: e.target.checked, useImageAspectRatio: e.target.checked,
@ -125,6 +147,124 @@ const Controls = () => {
/> />
</Option.Control> </Option.Control>
</Option> </Option>
<Divider my="sm" />
<Option>
<Option.Label>Follow current lyrics</Option.Label>
<Option.Control>
<Switch
checked={lyricConfig.follow}
onChange={(e) =>
handleLyricsSettings('follow', e.currentTarget.checked)
}
/>
</Option.Control>
</Option>
<Option>
<Option.Label>Show lyrics provider</Option.Label>
<Option.Control>
<Switch
checked={lyricConfig.showProvider}
onChange={(e) =>
handleLyricsSettings('showProvider', e.currentTarget.checked)
}
/>
</Option.Control>
</Option>
<Option>
<Option.Label>Show lyrics match</Option.Label>
<Option.Control>
<Switch
checked={lyricConfig.showMatch}
onChange={(e) =>
handleLyricsSettings('showMatch', e.currentTarget.checked)
}
/>
</Option.Control>
</Option>
<Option>
<Option.Label>Lyrics size</Option.Label>
<Option.Control>
<Group
noWrap
w="100%"
>
<Slider
defaultValue={lyricConfig.fontSize}
label={(e) => `Synchronized: ${e}px`}
max={72}
min={8}
w="100%"
onChangeEnd={(e) => handleLyricsSettings('fontSize', Number(e))}
/>
<Slider
defaultValue={lyricConfig.fontSize}
label={(e) => `Unsynchronized: ${e}px`}
max={72}
min={8}
w="100%"
onChangeEnd={(e) =>
handleLyricsSettings('fontSizeUnsync', Number(e))
}
/>
</Group>
</Option.Control>
</Option>
<Option>
<Option.Label>Lyrics gap</Option.Label>
<Option.Control>
<Group
noWrap
w="100%"
>
<Slider
defaultValue={lyricConfig.gap}
label={(e) => `Synchronized: ${e}px`}
max={50}
min={0}
w="100%"
onChangeEnd={(e) => handleLyricsSettings('gap', Number(e))}
/>
<Slider
defaultValue={lyricConfig.gap}
label={(e) => `Unsynchronized: ${e}px`}
max={50}
min={0}
w="100%"
onChangeEnd={(e) =>
handleLyricsSettings('gapUnsync', Number(e))
}
/>
</Group>
</Option.Control>
</Option>
<Option>
<Option.Label>Lyrics alignment</Option.Label>
<Option.Control>
<Select
data={[
{ label: 'Left', value: 'left' },
{ label: 'Center', value: 'center' },
{ label: 'Right', value: 'right' },
]}
value={lyricConfig.alignment}
onChange={(e) => handleLyricsSettings('alignment', e)}
/>
</Option.Control>
</Option>
<Option>
<Option.Label>Lyrics offset (ms)</Option.Label>
<Option.Control>
<NumberInput
defaultValue={lyricConfig.delayMs}
hideControls={false}
step={10}
onBlur={(e) =>
handleLyricsSettings('delayMs', Number(e.currentTarget.value))
}
/>
</Option.Control>
</Option>
<Divider my="sm" />
<TableConfigDropdown type="fullScreen" /> <TableConfigDropdown type="fullScreen" />
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>

View file

@ -132,9 +132,16 @@ export interface SettingsState {
globalMediaHotkeys: boolean; globalMediaHotkeys: boolean;
}; };
lyrics: { lyrics: {
alignment: 'left' | 'center' | 'right';
delayMs: number; delayMs: number;
fetch: boolean; fetch: boolean;
follow: boolean; follow: boolean;
fontSize: number;
fontSizeUnsync: number;
gap: number;
gapUnsync: number;
showMatch: boolean;
showProvider: boolean;
sources: LyricSource[]; sources: LyricSource[];
}; };
playback: { playback: {
@ -236,9 +243,16 @@ const initialState: SettingsState = {
globalMediaHotkeys: true, globalMediaHotkeys: true,
}, },
lyrics: { lyrics: {
alignment: 'center',
delayMs: 0, delayMs: 0,
fetch: false, fetch: false,
follow: true, follow: true,
fontSize: 46,
fontSizeUnsync: 20,
gap: 5,
gapUnsync: 0,
showMatch: true,
showProvider: true,
sources: [], sources: [],
}, },
playback: { playback: {