add waveform playerbar slider

This commit is contained in:
jeffvli
2025-11-18 02:41:17 -08:00
parent 142a6d6512
commit 7b9007c699
13 changed files with 1162 additions and 56 deletions
@@ -10,7 +10,11 @@ import {
usePlayerSpeed,
} from '/@/renderer/store';
import {
BarAlign,
PlayerbarSliderType,
useGeneralSettings,
usePlaybackSettings,
usePlayerbarSlider,
useSettingsStore,
useSettingsStoreActions,
} from '/@/renderer/store/settings.store';
@@ -31,6 +35,8 @@ export const PlayerConfig = () => {
const playbackSettings = usePlaybackSettings();
const { setSettings } = useSettingsStoreActions();
const speedPreservePitch = useSettingsStore((state) => state.playback.preservePitch);
const playerbarSlider = usePlayerbarSlider();
const generalSettings = useGeneralSettings();
const options = useMemo(() => {
const formatPlaybackSpeedSliderLabel = (value: number) => {
@@ -62,9 +68,14 @@ export const PlayerConfig = () => {
/>
),
id: 'queueType',
label: t('player.queueType', { postProcess: 'sentenceCase' }),
label: t('player.queueType', { postProcess: 'titleCase' }),
},
{
component: null,
id: 'divider-1',
isDivider: true,
label: '',
},
...(playbackSettings.type === PlayerType.WEB
? [
{
@@ -94,7 +105,7 @@ export const PlayerConfig = () => {
),
id: 'transitionType',
label: t('setting.playbackStyle', {
postProcess: 'sentenceCase',
postProcess: 'titleCase',
}),
},
]
@@ -124,36 +135,191 @@ export const PlayerConfig = () => {
),
id: 'crossfadeDuration',
label: t('setting.crossfadeDuration', {
postProcess: 'sentenceCase',
postProcess: 'titleCase',
}),
},
]
: []),
...(playbackSettings.type === PlayerType.WEB
{
component: (
<SegmentedControl
data={[
{
label: t('setting.playerbarSliderType', {
context: 'optionSlider',
postProcess: 'titleCase',
}),
value: PlayerbarSliderType.SLIDER,
},
{
label: t('setting.playerbarSliderType', {
context: 'optionWaveform',
postProcess: 'titleCase',
}),
value: PlayerbarSliderType.WAVEFORM,
},
]}
onChange={(value) => {
setSettings({
general: {
...generalSettings,
playerbarSlider: {
...playerbarSlider,
type: value as PlayerbarSliderType,
},
},
});
}}
size="sm"
value={playerbarSlider?.type || PlayerbarSliderType.WAVEFORM}
w="100%"
/>
),
id: 'playerbarSliderType',
label: t('setting.playerbarSlider', { postProcess: 'titleCase' }),
},
...(playerbarSlider?.type === PlayerbarSliderType.WAVEFORM
? [
{
component: (
<Switch
defaultChecked={speedPreservePitch}
onChange={(e) => {
<SegmentedControl
data={[
{
label: t('setting.playerbarWaveformAlign', {
context: 'optionTop',
postProcess: 'titleCase',
}),
value: BarAlign.TOP,
},
{
label: t('setting.playerbarWaveformAlign', {
context: 'optionCenter',
postProcess: 'titleCase',
}),
value: BarAlign.CENTER,
},
{
label: t('setting.playerbarWaveformAlign', {
context: 'optionBottom',
postProcess: 'titleCase',
}),
value: BarAlign.BOTTOM,
},
]}
onChange={(value) => {
setSettings({
playback: {
...playbackSettings,
preservePitch: e.currentTarget.checked,
general: {
...generalSettings,
playerbarSlider: {
...playerbarSlider,
barAlign: (value as BarAlign) || BarAlign.CENTER,
},
},
});
}}
size="sm"
value={playerbarSlider?.barAlign || BarAlign.CENTER}
w="100%"
/>
),
id: 'preservePitch',
label: t('setting.preservePitch', {
postProcess: 'sentenceCase',
id: 'barAlign',
label: t('setting.playerbarWaveformAlign', {
postProcess: 'titleCase',
}),
},
{
component: (
<Slider
defaultValue={playerbarSlider?.barWidth ?? 2}
max={10}
min={0}
onChangeEnd={(value) => {
setSettings({
general: {
...generalSettings,
playerbarSlider: {
...playerbarSlider,
barWidth: value,
},
},
});
}}
step={1}
styles={{
root: {},
}}
w="100%"
/>
),
id: 'barWidth',
label: t('setting.playerbarWaveformBarWidth', {
postProcess: 'titleCase',
}),
},
{
component: (
<Slider
defaultValue={playerbarSlider?.barGap || 0}
max={10}
min={0}
onChangeEnd={(value) => {
setSettings({
general: {
...generalSettings,
playerbarSlider: {
...playerbarSlider,
barGap: value,
},
},
});
}}
step={1}
styles={{
root: {},
}}
w="100%"
/>
),
id: 'barGap',
label: t('setting.playerbarWaveformGap', { postProcess: 'titleCase' }),
},
{
component: (
<Slider
defaultValue={playerbarSlider?.barRadius ?? 4}
max={20}
min={0}
onChangeEnd={(value) => {
setSettings({
general: {
...generalSettings,
playerbarSlider: {
...playerbarSlider,
barRadius: value,
},
},
});
}}
step={1}
styles={{
root: {},
}}
w="100%"
/>
),
id: 'barRadius',
label: t('setting.playerbarWaveformRadius', {
postProcess: 'titleCase',
}),
},
]
: []),
{
component: null,
id: 'divider-2',
isDivider: true,
label: '',
},
{
component: (
<Slider
@@ -181,8 +347,31 @@ export const PlayerConfig = () => {
/>
),
id: 'playbackSpeed',
label: t('player.playbackSpeed', { postProcess: 'sentenceCase' }),
label: t('player.playbackSpeed', { postProcess: 'titleCase' }),
},
...(speed !== 1
? [
{
component: (
<Switch
defaultChecked={speedPreservePitch}
onChange={(e) => {
setSettings({
playback: {
...playbackSettings,
preservePitch: e.currentTarget.checked,
},
});
}}
/>
),
id: 'preservePitch',
label: t('setting.preservePitch', {
postProcess: 'titleCase',
}),
},
]
: []),
];
return allOptions;
@@ -199,6 +388,8 @@ export const PlayerConfig = () => {
setTransitionType,
crossfadeDuration,
setCrossfadeDuration,
playerbarSlider,
generalSettings,
t,
]);
@@ -74,3 +74,14 @@
align-items: center;
height: 100%;
}
.wavesurfer-container {
width: 100%;
cursor: pointer;
}
.waveform {
align-items: center;
width: 100%;
height: 100%;
}
@@ -2,6 +2,7 @@ import formatDuration from 'format-duration';
import { useEffect, useRef, useState } from 'react';
import styles from './playerbar-slider.module.css';
import { PlayerbarWaveform } from './playerbar-waveform';
import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
@@ -14,14 +15,16 @@ import {
usePlayerSong,
usePlayerTimestamp,
} from '/@/renderer/store';
import { PlayerbarSliderType, usePlayerbarSlider } from '/@/renderer/store/settings.store';
import { Slider, SliderProps } from '/@/shared/components/slider/slider';
import { Text } from '/@/shared/components/text/text';
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
import { PlayerType } from '/@/shared/types/types';
export const PlayerbarSlider = ({ ...props }: SliderProps) => {
export const PlayerbarSlider = () => {
const playbackType = usePlaybackType();
const currentSong = usePlayerSong();
const playerbarSlider = usePlayerbarSlider();
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
const [isSeeking, setIsSeeking] = useState(false);
@@ -52,6 +55,8 @@ export const PlayerbarSlider = ({ ...props }: SliderProps) => {
useRemote();
const isWaveform = playerbarSlider?.type === PlayerbarSliderType.WAVEFORM;
return (
<>
<div className={styles.sliderContainer}>
@@ -68,38 +73,40 @@ export const PlayerbarSlider = ({ ...props }: SliderProps) => {
</Text>
</div>
<div className={styles.sliderWrapper}>
<CustomPlayerbarSlider
{...props}
label={(value) => formatDuration(value * 1000)}
max={songDuration}
min={0}
onChange={(e) => {
// Cancel any pending timeout if user starts seeking again
if (seekTimeoutRef.current) {
clearTimeout(seekTimeoutRef.current);
seekTimeoutRef.current = null;
}
setIsSeeking(true);
setSeekValue(e);
}}
onChangeEnd={(e) => {
setSeekValue(e);
handleSeekToTimestamp(e);
{isWaveform ? (
<PlayerbarWaveform />
) : (
<CustomPlayerbarSlider
label={(value) => formatDuration(value * 1000)}
max={songDuration}
min={0}
onChange={(e) => {
// Cancel any pending timeout if user starts seeking again
if (seekTimeoutRef.current) {
clearTimeout(seekTimeoutRef.current);
seekTimeoutRef.current = null;
}
setIsSeeking(true);
setSeekValue(e);
}}
onChangeEnd={(e) => {
setSeekValue(e);
handleSeekToTimestamp(e);
// Delay resetting isSeeking to allow currentTime to catch up
// This prevents the slider from flickering back and forth
seekTimeoutRef.current = setTimeout(() => {
setIsSeeking(false);
seekTimeoutRef.current = null;
}, 300);
}}
onClick={(e) => {
e?.stopPropagation();
}}
size={6}
value={!isSeeking ? currentTime : seekValue}
w="100%"
/>
// Delay resetting isSeeking to allow currentTime to catch up
seekTimeoutRef.current = setTimeout(() => {
setIsSeeking(false);
seekTimeoutRef.current = null;
}, 300);
}}
onClick={(e) => {
e?.stopPropagation();
}}
size={6}
value={!isSeeking ? currentTime : seekValue}
w="100%"
/>
)}
</div>
<div className={styles.sliderValueWrapper}>
<Text
@@ -0,0 +1,189 @@
import { useWavesurfer } from '@wavesurfer/react';
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useRef, useState } from 'react';
import styles from './playerbar-slider.module.css';
import { api } from '/@/renderer/api';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import {
BarAlign,
useGeneralSettings,
usePlaybackSettings,
usePlayerSong,
usePlayerTimestamp,
usePrimaryColor,
} from '/@/renderer/store';
import { useColorScheme } from '/@/renderer/themes/use-app-theme';
import { Spinner } from '/@/shared/components/spinner/spinner';
export const PlayerbarWaveform = () => {
const currentSong = usePlayerSong();
const { transcode } = usePlaybackSettings();
const { playerbarSlider } = useGeneralSettings();
const currentTime = usePlayerTimestamp();
const containerRef = useRef<HTMLDivElement>(null);
const { mediaSeekToTimestamp } = usePlayer();
const [isLoading, setIsLoading] = useState(true);
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
// Get the stream URL with transcoding support
const streamUrl = useMemo(() => {
if (!currentSong?._serverId || !currentSong?.streamUrl) {
return null;
}
if (!transcode.enabled) {
return currentSong.streamUrl;
}
return api.controller.getTranscodingUrl({
apiClientProps: {
serverId: currentSong._serverId,
},
query: {
base: currentSong.streamUrl,
...transcode,
},
});
}, [currentSong, transcode]);
const primaryColor = usePrimaryColor();
const colorScheme = useColorScheme();
const waveColor = useMemo(() => {
return colorScheme === 'dark' ? 'rgba(96, 96, 96, 1)' : 'rgba(96, 96, 96, 1)';
}, [colorScheme]);
const cursorColor = useMemo(() => {
return colorScheme === 'dark' ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)';
}, [colorScheme]);
const { wavesurfer } = useWavesurfer({
barAlign:
playerbarSlider?.barAlign === BarAlign.CENTER ? undefined : playerbarSlider?.barAlign,
barGap: playerbarSlider?.barGap,
barRadius: playerbarSlider?.barRadius,
barWidth: playerbarSlider?.barWidth,
container: containerRef,
cursorColor,
cursorWidth: 2,
fillParent: true,
height: 18,
interact: true,
normalize: false,
progressColor: primaryColor,
url: streamUrl || undefined,
waveColor,
});
// Reset loading state when stream URL changes
useEffect(() => {
setIsLoading(true);
}, [streamUrl]);
// Handle waveform ready state
useEffect(() => {
if (!wavesurfer) return;
const handleReady = () => {
setIsLoading(false);
};
wavesurfer.on('ready', handleReady);
// Check if already loaded
if (wavesurfer.getDuration() > 0) {
setIsLoading(false);
}
return () => {
wavesurfer.un('ready', handleReady);
};
}, [wavesurfer]);
useEffect(() => {
if (!wavesurfer) return;
// Ensure waveform never plays - it's just for visualization
const preventPlay = () => {
wavesurfer.pause();
};
wavesurfer.on('play', preventPlay);
return () => {
wavesurfer.un('play', preventPlay);
};
}, [wavesurfer]);
// Handle seeking when user clicks on waveform
useEffect(() => {
if (!wavesurfer || !songDuration) return;
const handleInteraction = () => {
const seekTime = wavesurfer.getCurrentTime();
const duration = wavesurfer.getDuration();
if (duration > 0) {
mediaSeekToTimestamp(seekTime);
}
};
wavesurfer.on('interaction', handleInteraction);
return () => {
wavesurfer.un('interaction', handleInteraction);
};
}, [wavesurfer, songDuration, mediaSeekToTimestamp]);
// Update waveform progress based on player current time
useEffect(() => {
if (!wavesurfer || !songDuration) return;
const duration = wavesurfer.getDuration();
if (duration > 0 && currentTime >= 0) {
const ratio = currentTime / duration;
wavesurfer.seekTo(ratio);
}
}, [wavesurfer, currentTime, songDuration]);
return (
<div
className={styles.wavesurferContainer}
onClick={(e) => {
e?.stopPropagation();
}}
style={{ position: 'relative' }}
>
<motion.div
animate={{ opacity: isLoading ? 0 : 1 }}
className={styles.waveform}
initial={{ opacity: 0 }}
ref={containerRef}
transition={{ duration: 0.2 }}
/>
<AnimatePresence>
{isLoading && (
<motion.div
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
style={{
height: '100%',
left: 0,
position: 'absolute',
top: 0,
width: '100%',
}}
transition={{ duration: 0.2 }}
>
<Spinner container />
</motion.div>
)}
</AnimatePresence>
</div>
);
};