mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-16 08:24:16 +02:00
add waveform playerbar slider
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user