Add support for OpenSubsonic enhanced lyrics (#2208)

This commit is contained in:
Jeff
2026-07-09 21:32:05 -07:00
committed by GitHub
parent 0c89fea1ea
commit 355b19c1cb
55 changed files with 4213 additions and 480 deletions
@@ -162,7 +162,7 @@ export function MpvPlayer() {
try {
const time = await mpvPlayer.getCurrentTime();
if (time !== undefined) {
setTimestamp(Number(time.toFixed(0)));
setTimestamp(time);
}
} catch {
// Do nothing
@@ -78,6 +78,10 @@ export function WaveSurferPlayer() {
return;
}
if (num === 1) {
setTimestamp(e.playedSeconds);
}
switch (transitionType) {
case PlayerStyle.CROSSFADE:
crossfadeHandler({
@@ -106,7 +110,7 @@ export function WaveSurferPlayer() {
break;
}
},
[crossfadeDuration, isTransitioning, num, player2, transitionType, volume],
[crossfadeDuration, isTransitioning, num, player2, setTimestamp, transitionType, volume],
);
const onProgressPlayer2 = useCallback(
@@ -115,6 +119,10 @@ export function WaveSurferPlayer() {
return;
}
if (num === 2) {
setTimestamp(e.playedSeconds);
}
switch (transitionType) {
case PlayerStyle.CROSSFADE:
crossfadeHandler({
@@ -143,7 +151,7 @@ export function WaveSurferPlayer() {
break;
}
},
[crossfadeDuration, isTransitioning, num, player1, transitionType, volume],
[crossfadeDuration, isTransitioning, num, player1, setTimestamp, transitionType, volume],
);
const handleOnEndedPlayer1 = useCallback(() => {
@@ -237,7 +245,7 @@ export function WaveSurferPlayer() {
transitionType === PlayerStyle.CROSSFADE ||
transitionType === PlayerStyle.GAPLESS
) {
setTimestamp(Number(currentTime.toFixed(0)));
setTimestamp(currentTime);
}
}, 500);
@@ -127,6 +127,10 @@ export function WebPlayer() {
return;
}
if (num === 1) {
setTimestamp(e.playedSeconds);
}
if (repeat === PlayerRepeat.ONE) {
handleRepeatOne(1, e.playedSeconds, getDuration(playerRef.current.player1().ref));
return;
@@ -170,6 +174,7 @@ export function WebPlayer() {
num,
player2,
repeat,
setTimestamp,
transitionType,
volume,
],
@@ -181,6 +186,10 @@ export function WebPlayer() {
return;
}
if (num === 2) {
setTimestamp(e.playedSeconds);
}
if (repeat === PlayerRepeat.ONE) {
handleRepeatOne(2, e.playedSeconds, getDuration(playerRef.current.player2().ref));
return;
@@ -224,6 +233,7 @@ export function WebPlayer() {
num,
player1,
repeat,
setTimestamp,
transitionType,
volume,
],
@@ -388,7 +398,7 @@ export function WebPlayer() {
transitionType === PlayerStyle.CROSSFADE ||
transitionType === PlayerStyle.GAPLESS
) {
setTimestamp(Number(currentTime.toFixed(0)));
setTimestamp(currentTime);
}
}, 500);
@@ -248,7 +248,14 @@ const Controls = () => {
};
const handleLyricsSettings = (property: string, value: any) => {
const displayProperties = ['fontSize', 'fontSizeUnsync', 'gap', 'gapUnsync'];
const displayProperties = [
'fontSize',
'fontSizeUnsync',
'gap',
'gapUnsync',
'paddingLeft',
'paddingRight',
];
if (displayProperties.includes(property)) {
const currentDisplay = useSettingsStore.getState().lyricsDisplay;
setSettings({
@@ -475,6 +482,38 @@ const Controls = () => {
</Group>
</Option.Control>
</Option>
<Option>
<Option.Label>
{t('page.fullscreenPlayer.config.lyricPaddingLeft')}
</Option.Label>
<Option.Control>
<Slider
defaultValue={lyricConfig.paddingLeft ?? 0}
label={(value) => `${value}%`}
max={20}
min={0}
onChangeEnd={(value) => handleLyricsSettings('paddingLeft', value)}
step={1}
w="100%"
/>
</Option.Control>
</Option>
<Option>
<Option.Label>
{t('page.fullscreenPlayer.config.lyricPaddingRight')}
</Option.Label>
<Option.Control>
<Slider
defaultValue={lyricConfig.paddingRight ?? 0}
label={(value) => `${value}%`}
max={20}
min={0}
onChangeEnd={(value) => handleLyricsSettings('paddingRight', value)}
step={1}
w="100%"
/>
</Option.Control>
</Option>
<Option>
<Option.Label>
{t('page.fullscreenPlayer.config.lyricAlignment')}
@@ -51,7 +51,14 @@ export const MobileFullscreenPlayerHeader = memo(
const lyricConfig = { ...lyricsSettings, ...displaySettings };
const handleLyricsSettings = (property: string, value: any) => {
const displayProperties = ['fontSize', 'fontSizeUnsync', 'gap', 'gapUnsync'];
const displayProperties = [
'fontSize',
'fontSizeUnsync',
'gap',
'gapUnsync',
'paddingLeft',
'paddingRight',
];
if (displayProperties.includes(property)) {
const currentDisplay = useSettingsStore.getState().lyricsDisplay;
setSettings({
@@ -284,6 +291,42 @@ export const MobileFullscreenPlayerHeader = memo(
</Group>
</Option.Control>
</Option>
<Option>
<Option.Label>
{t('page.fullscreenPlayer.config.lyricPaddingLeft')}
</Option.Label>
<Option.Control>
<Slider
defaultValue={lyricConfig.paddingLeft ?? 0}
label={(value) => `${value}%`}
max={20}
min={0}
onChangeEnd={(value) =>
handleLyricsSettings('paddingLeft', value)
}
step={1}
w="100%"
/>
</Option.Control>
</Option>
<Option>
<Option.Label>
{t('page.fullscreenPlayer.config.lyricPaddingRight')}
</Option.Label>
<Option.Control>
<Slider
defaultValue={lyricConfig.paddingRight ?? 0}
label={(value) => `${value}%`}
max={20}
min={0}
onChangeEnd={(value) =>
handleLyricsSettings('paddingRight', value)
}
step={1}
w="100%"
/>
</Option.Control>
</Option>
<Option>
<Option.Label>
{t('page.fullscreenPlayer.config.lyricAlignment')}
@@ -32,7 +32,6 @@ export const PlayerbarSlider = () => {
const formattedDuration = formatDuration(songDuration * 1000 || 0);
const formattedTimeRemaining = formatDuration((currentTime - songDuration) * 1000 || 0);
const formattedTime = formatDuration(currentTime * 1000 || 0);
const showTimeRemaining = useAppStore((state) => state.showTimeRemaining);
const { setShowTimeRemaining } = useAppStoreActions();
@@ -43,7 +42,7 @@ export const PlayerbarSlider = () => {
<>
<div className={styles.sliderContainer}>
<div className={styles.sliderValueWrapper}>
<ScrobbleStatus formattedTime={formattedTime} />
<ScrobbleStatus />
</div>
<div className={styles.sliderWrapper}>
{isWaveform ? (
@@ -1,10 +1,16 @@
import formatDuration from 'format-duration';
import { useTranslation } from 'react-i18next';
import {
invokeScrobbleForceSubmit,
invokeScrobbleResetListenedState,
} from '/@/renderer/features/player/hooks/use-scrobble';
import { useAppStore, useScrobbleDebugStore, useSettingsStore } from '/@/renderer/store';
import {
useAppStore,
usePlayerTimestamp,
useScrobbleDebugSnapshot,
useSettingsStore,
} from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { HoverCard } from '/@/shared/components/hover-card/hover-card';
@@ -32,11 +38,12 @@ const ScrobbleConditionProgress = ({ value }: { value: number }) => (
<Progress {...scrobbleProgressProps} value={value} w="100%" />
);
export const ScrobbleStatus = ({ formattedTime }: { formattedTime: string }) => {
export const ScrobbleStatus = () => {
const { t } = useTranslation();
const scrobbleEnabled = useSettingsStore((state) => state.playback.scrobble.enabled);
const privateMode = useAppStore((state) => state.privateMode);
const snapshot = useScrobbleDebugStore((state) => state.snapshot);
const snapshot = useScrobbleDebugSnapshot();
const formattedTime = formatDuration(usePlayerTimestamp() * 1000 || 0);
const hookInactive = !scrobbleEnabled || privateMode;