mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
add waveform playerbar slider
This commit is contained in:
@@ -79,6 +79,7 @@
|
|||||||
"@tanstack/react-query-persist-client": "^5.90.11",
|
"@tanstack/react-query-persist-client": "^5.90.11",
|
||||||
"@ts-rest/core": "^3.52.1",
|
"@ts-rest/core": "^3.52.1",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
|
"@wavesurfer/react": "^1.0.11",
|
||||||
"@xhayper/discord-rpc": "^1.3.0",
|
"@xhayper/discord-rpc": "^1.3.0",
|
||||||
"audiomotion-analyzer": "^4.5.1",
|
"audiomotion-analyzer": "^4.5.1",
|
||||||
"auto-text-size": "^0.2.3",
|
"auto-text-size": "^0.2.3",
|
||||||
@@ -126,6 +127,7 @@
|
|||||||
"react-window-v2": "npm:react-window@^2.2.3",
|
"react-window-v2": "npm:react-window@^2.2.3",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"string-to-color": "^2.2.2",
|
"string-to-color": "^2.2.2",
|
||||||
|
"wavesurfer.js": "^7.11.1",
|
||||||
"ws": "^8.18.2",
|
"ws": "^8.18.2",
|
||||||
"zod": "^3.22.3",
|
"zod": "^3.22.3",
|
||||||
"zustand": "^5.0.5"
|
"zustand": "^5.0.5"
|
||||||
|
|||||||
Generated
+22
@@ -65,6 +65,9 @@ importers:
|
|||||||
'@types/react-window':
|
'@types/react-window':
|
||||||
specifier: ^1.8.8
|
specifier: ^1.8.8
|
||||||
version: 1.8.8
|
version: 1.8.8
|
||||||
|
'@wavesurfer/react':
|
||||||
|
specifier: ^1.0.11
|
||||||
|
version: 1.0.11(react@19.1.0)(wavesurfer.js@7.11.1)
|
||||||
'@xhayper/discord-rpc':
|
'@xhayper/discord-rpc':
|
||||||
specifier: ^1.3.0
|
specifier: ^1.3.0
|
||||||
version: 1.3.0
|
version: 1.3.0
|
||||||
@@ -206,6 +209,9 @@ importers:
|
|||||||
string-to-color:
|
string-to-color:
|
||||||
specifier: ^2.2.2
|
specifier: ^2.2.2
|
||||||
version: 2.2.2
|
version: 2.2.2
|
||||||
|
wavesurfer.js:
|
||||||
|
specifier: ^7.11.1
|
||||||
|
version: 7.11.1
|
||||||
ws:
|
ws:
|
||||||
specifier: ^8.18.2
|
specifier: ^8.18.2
|
||||||
version: 8.18.2
|
version: 8.18.2
|
||||||
@@ -2069,6 +2075,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==}
|
resolution: {integrity: sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==}
|
||||||
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
||||||
|
|
||||||
|
'@wavesurfer/react@1.0.11':
|
||||||
|
resolution: {integrity: sha512-DRpaA3MRTKy4Jby12xvoHASa+w31FZtxaqanXcJjfqNqfamkKi8VJfRnz+Uub9LkpdgoAc3g5SuZF75lEcGgzQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18.2.0 || ^19.0.0
|
||||||
|
wavesurfer.js: '>=7.7.14'
|
||||||
|
|
||||||
'@xhayper/discord-rpc@1.3.0':
|
'@xhayper/discord-rpc@1.3.0':
|
||||||
resolution: {integrity: sha512-0NmUTiODl7u3UEjmO6y0Syp3dmgVLAt2EHrH4QKTQcXRwtF8Wl7Eipdn/GSSZ8HkDwxQFvcDGJMxT9VWB0pH8g==}
|
resolution: {integrity: sha512-0NmUTiODl7u3UEjmO6y0Syp3dmgVLAt2EHrH4QKTQcXRwtF8Wl7Eipdn/GSSZ8HkDwxQFvcDGJMxT9VWB0pH8g==}
|
||||||
engines: {node: '>=18.20.7'}
|
engines: {node: '>=18.20.7'}
|
||||||
@@ -5601,6 +5613,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-IC8sL7aB4/ZgFcGI2T1LczZeFWZ06b3zoHH7jBPyHxOtIIz1jppWHjjEXkOFvFojBVAK9pV7g47xOZ4LW3QLfg==}
|
resolution: {integrity: sha512-IC8sL7aB4/ZgFcGI2T1LczZeFWZ06b3zoHH7jBPyHxOtIIz1jppWHjjEXkOFvFojBVAK9pV7g47xOZ4LW3QLfg==}
|
||||||
engines: {node: 8.* || >= 10.*}
|
engines: {node: 8.* || >= 10.*}
|
||||||
|
|
||||||
|
wavesurfer.js@7.11.1:
|
||||||
|
resolution: {integrity: sha512-8Q+wwItpjJAlhQ7crQLtKwgfbqqczm5/wx+76K4PptP+MBAjB0OA78+A9OuLnULz/8GpAQ+fKM6s81DonEO0Sg==}
|
||||||
|
|
||||||
wcwidth@1.0.1:
|
wcwidth@1.0.1:
|
||||||
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
||||||
|
|
||||||
@@ -7716,6 +7731,11 @@ snapshots:
|
|||||||
|
|
||||||
'@vladfrangu/async_event_emitter@2.4.6': {}
|
'@vladfrangu/async_event_emitter@2.4.6': {}
|
||||||
|
|
||||||
|
'@wavesurfer/react@1.0.11(react@19.1.0)(wavesurfer.js@7.11.1)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.1.0
|
||||||
|
wavesurfer.js: 7.11.1
|
||||||
|
|
||||||
'@xhayper/discord-rpc@1.3.0':
|
'@xhayper/discord-rpc@1.3.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@discordjs/rest': 2.5.1
|
'@discordjs/rest': 2.5.1
|
||||||
@@ -11637,6 +11657,8 @@ snapshots:
|
|||||||
matcher-collection: 2.0.1
|
matcher-collection: 2.0.1
|
||||||
minimatch: 3.1.2
|
minimatch: 3.1.2
|
||||||
|
|
||||||
|
wavesurfer.js@7.11.1: {}
|
||||||
|
|
||||||
wcwidth@1.0.1:
|
wcwidth@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
defaults: 1.0.4
|
defaults: 1.0.4
|
||||||
|
|||||||
@@ -725,6 +725,16 @@
|
|||||||
"playerAlbumArtResolution": "player album art resolution",
|
"playerAlbumArtResolution": "player album art resolution",
|
||||||
"playerbarOpenDrawer_description": "allows clicking of the playerbar to open the full screen player",
|
"playerbarOpenDrawer_description": "allows clicking of the playerbar to open the full screen player",
|
||||||
"playerbarOpenDrawer": "playerbar fullscreen toggle",
|
"playerbarOpenDrawer": "playerbar fullscreen toggle",
|
||||||
|
"playerbarSlider": "playerbar slider",
|
||||||
|
"playerbarSliderType_optionSlider": "slider",
|
||||||
|
"playerbarSliderType_optionWaveform": "waveform",
|
||||||
|
"playerbarWaveformAlign": "waveform align",
|
||||||
|
"playerbarWaveformAlign_optionTop": "top",
|
||||||
|
"playerbarWaveformAlign_optionCenter": "center",
|
||||||
|
"playerbarWaveformAlign_optionBottom": "bottom",
|
||||||
|
"playerbarWaveformBarWidth": "waveform bar width",
|
||||||
|
"playerbarWaveformGap": "waveform gap",
|
||||||
|
"playerbarWaveformRadius": "waveform radius",
|
||||||
"preferLocalLyrics_description": "prefer local lyrics over remote lyrics when available",
|
"preferLocalLyrics_description": "prefer local lyrics over remote lyrics when available",
|
||||||
"preferLocalLyrics": "prefer local lyrics",
|
"preferLocalLyrics": "prefer local lyrics",
|
||||||
"preservePitch_description": "preserves pitch when modifying playback speed",
|
"preservePitch_description": "preserves pitch when modifying playback speed",
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import type { RefObject } from 'react';
|
||||||
|
import type WaveSurfer from 'wavesurfer.js';
|
||||||
|
|
||||||
|
import { useWavesurfer } from '@wavesurfer/react';
|
||||||
|
import { useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { AudioPlayer, PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types';
|
||||||
|
import { convertToLogVolume } from '/@/renderer/features/player/audio-player/utils/player-utils';
|
||||||
|
import { PlayerStatus } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
export interface WaveSurferPlayerEngineHandle extends AudioPlayer {
|
||||||
|
player1(): {
|
||||||
|
ref: null | WaveSurfer;
|
||||||
|
setVolume: (volume: number) => void;
|
||||||
|
};
|
||||||
|
player2(): {
|
||||||
|
ref: null | WaveSurfer;
|
||||||
|
setVolume: (volume: number) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WaveSurferPlayerEngineProps {
|
||||||
|
isMuted: boolean;
|
||||||
|
isTransitioning: boolean;
|
||||||
|
onEndedPlayer1: () => void;
|
||||||
|
onEndedPlayer2: () => void;
|
||||||
|
onProgressPlayer1: (e: PlayerOnProgressProps) => void;
|
||||||
|
onProgressPlayer2: (e: PlayerOnProgressProps) => void;
|
||||||
|
playerNum: number;
|
||||||
|
playerRef: RefObject<null | WaveSurferPlayerEngineHandle>;
|
||||||
|
playerStatus: PlayerStatus;
|
||||||
|
speed?: number;
|
||||||
|
src1: string | undefined;
|
||||||
|
src2: string | undefined;
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credits: https://gist.github.com/novwhisky/8a1a0168b94f3b6abfaa?permalink_comment_id=1551393#gistcomment-1551393
|
||||||
|
// This is used so that the player will always have an <audio> element. This means that
|
||||||
|
// player1Source and player2Source are connected BEFORE the user presses play for
|
||||||
|
// the first time. This workaround is important for Safari, which seems to require the
|
||||||
|
// source to be connected PRIOR to resuming audio context
|
||||||
|
const EMPTY_SOURCE =
|
||||||
|
'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV';
|
||||||
|
|
||||||
|
export const WaveSurferPlayerEngine = (props: WaveSurferPlayerEngineProps) => {
|
||||||
|
const {
|
||||||
|
isMuted,
|
||||||
|
isTransitioning,
|
||||||
|
onEndedPlayer1,
|
||||||
|
onEndedPlayer2,
|
||||||
|
onProgressPlayer1,
|
||||||
|
onProgressPlayer2,
|
||||||
|
playerNum,
|
||||||
|
playerRef,
|
||||||
|
playerStatus,
|
||||||
|
speed,
|
||||||
|
src1,
|
||||||
|
src2,
|
||||||
|
volume,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const container1Ref = useRef<HTMLDivElement>(null);
|
||||||
|
const container2Ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [internalVolume1, setInternalVolume1] = useState(volume / 100 || 0);
|
||||||
|
const [internalVolume2, setInternalVolume2] = useState(volume / 100 || 0);
|
||||||
|
|
||||||
|
const { wavesurfer: wavesurfer1 } = useWavesurfer({
|
||||||
|
barWidth: 0,
|
||||||
|
container: container1Ref,
|
||||||
|
cursorColor: 'transparent',
|
||||||
|
height: 0,
|
||||||
|
interact: false,
|
||||||
|
normalize: false,
|
||||||
|
progressColor: 'transparent',
|
||||||
|
url: src1 || EMPTY_SOURCE,
|
||||||
|
waveColor: 'transparent',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { wavesurfer: wavesurfer2 } = useWavesurfer({
|
||||||
|
barWidth: 0,
|
||||||
|
container: container2Ref,
|
||||||
|
cursorColor: 'transparent',
|
||||||
|
height: 0,
|
||||||
|
interact: false,
|
||||||
|
normalize: false,
|
||||||
|
progressColor: 'transparent',
|
||||||
|
url: src2 || EMPTY_SOURCE,
|
||||||
|
waveColor: 'transparent',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle volume changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (wavesurfer1) {
|
||||||
|
const logVolume1 = convertToLogVolume(internalVolume1);
|
||||||
|
wavesurfer1.setVolume(isMuted ? 0 : logVolume1);
|
||||||
|
}
|
||||||
|
}, [wavesurfer1, internalVolume1, isMuted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (wavesurfer2) {
|
||||||
|
const logVolume2 = convertToLogVolume(internalVolume2);
|
||||||
|
wavesurfer2.setVolume(isMuted ? 0 : logVolume2);
|
||||||
|
}
|
||||||
|
}, [wavesurfer2, internalVolume2, isMuted]);
|
||||||
|
|
||||||
|
// Handle playback rate (speed)
|
||||||
|
useEffect(() => {
|
||||||
|
if (wavesurfer1 && speed) {
|
||||||
|
wavesurfer1.setPlaybackRate(speed);
|
||||||
|
}
|
||||||
|
}, [wavesurfer1, speed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (wavesurfer2 && speed) {
|
||||||
|
wavesurfer2.setPlaybackRate(speed);
|
||||||
|
}
|
||||||
|
}, [wavesurfer2, speed]);
|
||||||
|
|
||||||
|
// Handle play/pause based on playerNum and status
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wavesurfer1 || !wavesurfer2) return;
|
||||||
|
|
||||||
|
if (playerNum === 1 && playerStatus === PlayerStatus.PLAYING) {
|
||||||
|
wavesurfer1.play();
|
||||||
|
} else {
|
||||||
|
wavesurfer1.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerNum === 2 && playerStatus === PlayerStatus.PLAYING) {
|
||||||
|
wavesurfer2.play();
|
||||||
|
} else {
|
||||||
|
wavesurfer2.pause();
|
||||||
|
}
|
||||||
|
}, [wavesurfer1, wavesurfer2, playerNum, playerStatus]);
|
||||||
|
|
||||||
|
// Handle progress updates for player1
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wavesurfer1 || !src1) return;
|
||||||
|
|
||||||
|
const updateProgress = () => {
|
||||||
|
const currentTime = wavesurfer1.getCurrentTime();
|
||||||
|
const duration = wavesurfer1.getDuration();
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
onProgressPlayer1({
|
||||||
|
played: currentTime / duration,
|
||||||
|
playedSeconds: currentTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(updateProgress, isTransitioning ? 10 : 250);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [wavesurfer1, src1, isTransitioning, onProgressPlayer1]);
|
||||||
|
|
||||||
|
// Handle progress updates for player2
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wavesurfer2 || !src2) return;
|
||||||
|
|
||||||
|
const updateProgress = () => {
|
||||||
|
const currentTime = wavesurfer2.getCurrentTime();
|
||||||
|
const duration = wavesurfer2.getDuration();
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
onProgressPlayer2({
|
||||||
|
played: currentTime / duration,
|
||||||
|
playedSeconds: currentTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(updateProgress, isTransitioning ? 10 : 250);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [wavesurfer2, src2, isTransitioning, onProgressPlayer2]);
|
||||||
|
|
||||||
|
// Handle ended events
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wavesurfer1 || !src1) return;
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
onEndedPlayer1();
|
||||||
|
};
|
||||||
|
|
||||||
|
wavesurfer1.on('finish', handleEnded);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
wavesurfer1.un('finish', handleEnded);
|
||||||
|
};
|
||||||
|
}, [wavesurfer1, src1, onEndedPlayer1]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wavesurfer2 || !src2) return;
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
onEndedPlayer2();
|
||||||
|
};
|
||||||
|
|
||||||
|
wavesurfer2.on('finish', handleEnded);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
wavesurfer2.un('finish', handleEnded);
|
||||||
|
};
|
||||||
|
}, [wavesurfer2, src2, onEndedPlayer2]);
|
||||||
|
|
||||||
|
useImperativeHandle<WaveSurferPlayerEngineHandle, WaveSurferPlayerEngineHandle>(
|
||||||
|
playerRef,
|
||||||
|
() => ({
|
||||||
|
decreaseVolume(by: number) {
|
||||||
|
setInternalVolume1(Math.max(0, internalVolume1 - by / 100));
|
||||||
|
setInternalVolume2(Math.max(0, internalVolume2 - by / 100));
|
||||||
|
},
|
||||||
|
increaseVolume(by: number) {
|
||||||
|
setInternalVolume1(Math.min(1, internalVolume1 + by / 100));
|
||||||
|
setInternalVolume2(Math.min(1, internalVolume2 + by / 100));
|
||||||
|
},
|
||||||
|
pause() {
|
||||||
|
wavesurfer1?.pause();
|
||||||
|
wavesurfer2?.pause();
|
||||||
|
},
|
||||||
|
play() {
|
||||||
|
if (playerNum === 1) {
|
||||||
|
wavesurfer1?.play();
|
||||||
|
} else {
|
||||||
|
wavesurfer2?.play();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
player1() {
|
||||||
|
return {
|
||||||
|
ref: wavesurfer1 || null,
|
||||||
|
setVolume: (volume: number) => setInternalVolume1(volume / 100 || 0),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
player2() {
|
||||||
|
return {
|
||||||
|
ref: wavesurfer2 || null,
|
||||||
|
setVolume: (volume: number) => setInternalVolume2(volume / 100 || 0),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
seekTo(seekTo: number) {
|
||||||
|
if (playerNum === 1) {
|
||||||
|
wavesurfer1?.seekTo(seekTo);
|
||||||
|
} else {
|
||||||
|
wavesurfer2?.seekTo(seekTo);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setVolume(volume: number) {
|
||||||
|
setInternalVolume1(volume / 100 || 0);
|
||||||
|
setInternalVolume2(volume / 100 || 0);
|
||||||
|
},
|
||||||
|
setVolume1(volume: number) {
|
||||||
|
setInternalVolume1(volume / 100 || 0);
|
||||||
|
},
|
||||||
|
setVolume2(volume: number) {
|
||||||
|
setInternalVolume2(volume / 100 || 0);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[wavesurfer1, wavesurfer2, playerNum, internalVolume1, internalVolume2],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="wavesurfer-player-engine" style={{ display: 'none' }}>
|
||||||
|
{Boolean(src1) && <div id="wavesurfer-player-1" ref={container1Ref} />}
|
||||||
|
{Boolean(src2) && <div id="wavesurfer-player-2" ref={container2Ref} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
WaveSurferPlayerEngine.displayName = 'WaveSurferPlayerEngine';
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import type { Dispatch } from 'react';
|
||||||
|
import type WaveSurfer from 'wavesurfer.js';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
WaveSurferPlayerEngine,
|
||||||
|
WaveSurferPlayerEngineHandle,
|
||||||
|
} from '/@/renderer/features/player/audio-player/engine/wavesurfer-player-engine';
|
||||||
|
import { useMainPlayerListener } from '/@/renderer/features/player/audio-player/hooks/use-main-player-listener';
|
||||||
|
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||||
|
import { PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types';
|
||||||
|
import {
|
||||||
|
usePlayerActions,
|
||||||
|
usePlayerData,
|
||||||
|
usePlayerMuted,
|
||||||
|
usePlayerProperties,
|
||||||
|
usePlayerVolume,
|
||||||
|
} from '/@/renderer/store';
|
||||||
|
import { PlayerStatus, PlayerStyle } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
const PLAY_PAUSE_FADE_DURATION = 300;
|
||||||
|
const PLAY_PAUSE_FADE_INTERVAL = 10;
|
||||||
|
|
||||||
|
export function WaveSurferPlayer() {
|
||||||
|
const playerRef = useRef<null | WaveSurferPlayerEngineHandle>(null);
|
||||||
|
const { num, player1, player2, status } = usePlayerData();
|
||||||
|
const { mediaAutoNext, setTimestamp } = usePlayerActions();
|
||||||
|
const { crossfadeDuration, speed, transitionType } = usePlayerProperties();
|
||||||
|
const isMuted = usePlayerMuted();
|
||||||
|
const volume = usePlayerVolume();
|
||||||
|
|
||||||
|
const [localPlayerStatus, setLocalPlayerStatus] = useState<PlayerStatus>(status);
|
||||||
|
const [isTransitioning, setIsTransitioning] = useState<boolean | string>(false);
|
||||||
|
|
||||||
|
const fadeAndSetStatus = useCallback(
|
||||||
|
async (startVolume: number, endVolume: number, duration: number, status: PlayerStatus) => {
|
||||||
|
if (isTransitioning) {
|
||||||
|
return setLocalPlayerStatus(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = duration / PLAY_PAUSE_FADE_INTERVAL;
|
||||||
|
const volumeStep = (endVolume - startVolume) / steps;
|
||||||
|
let currentStep = 0;
|
||||||
|
|
||||||
|
const promise = new Promise((resolve) => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
currentStep++;
|
||||||
|
const newVolume = startVolume + volumeStep * currentStep;
|
||||||
|
|
||||||
|
playerRef.current?.setVolume(newVolume);
|
||||||
|
|
||||||
|
if (currentStep >= steps) {
|
||||||
|
clearInterval(interval);
|
||||||
|
setIsTransitioning(false);
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
}, PLAY_PAUSE_FADE_INTERVAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status === PlayerStatus.PAUSED) {
|
||||||
|
await promise;
|
||||||
|
setLocalPlayerStatus(status);
|
||||||
|
} else if (status === PlayerStatus.PLAYING) {
|
||||||
|
setLocalPlayerStatus(status);
|
||||||
|
await promise;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isTransitioning],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onProgressPlayer1 = useCallback(
|
||||||
|
(e: PlayerOnProgressProps) => {
|
||||||
|
if (!playerRef.current?.player1()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (transitionType) {
|
||||||
|
case PlayerStyle.CROSSFADE:
|
||||||
|
crossfadeHandler({
|
||||||
|
crossfadeDuration: crossfadeDuration,
|
||||||
|
currentPlayer: playerRef.current.player1(),
|
||||||
|
currentPlayerNum: num,
|
||||||
|
currentTime: e.playedSeconds,
|
||||||
|
duration: getDuration(playerRef.current.player1().ref),
|
||||||
|
isTransitioning,
|
||||||
|
nextPlayer: playerRef.current.player2(),
|
||||||
|
playerNum: 1,
|
||||||
|
setIsTransitioning,
|
||||||
|
volume,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case PlayerStyle.GAPLESS:
|
||||||
|
gaplessHandler({
|
||||||
|
currentTime: e.playedSeconds,
|
||||||
|
duration: getDuration(playerRef.current.player1().ref),
|
||||||
|
isFlac: false,
|
||||||
|
isTransitioning,
|
||||||
|
nextPlayer: playerRef.current.player2(),
|
||||||
|
setIsTransitioning,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[crossfadeDuration, isTransitioning, num, transitionType, volume],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onProgressPlayer2 = useCallback(
|
||||||
|
(e: PlayerOnProgressProps) => {
|
||||||
|
if (!playerRef.current?.player2()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (transitionType) {
|
||||||
|
case PlayerStyle.CROSSFADE:
|
||||||
|
crossfadeHandler({
|
||||||
|
crossfadeDuration: crossfadeDuration,
|
||||||
|
currentPlayer: playerRef.current.player2(),
|
||||||
|
currentPlayerNum: num,
|
||||||
|
currentTime: e.playedSeconds,
|
||||||
|
duration: getDuration(playerRef.current.player2().ref),
|
||||||
|
isTransitioning,
|
||||||
|
nextPlayer: playerRef.current.player1(),
|
||||||
|
playerNum: 2,
|
||||||
|
setIsTransitioning,
|
||||||
|
volume,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case PlayerStyle.GAPLESS:
|
||||||
|
gaplessHandler({
|
||||||
|
currentTime: e.playedSeconds,
|
||||||
|
duration: getDuration(playerRef.current.player2().ref),
|
||||||
|
isFlac: false,
|
||||||
|
isTransitioning,
|
||||||
|
nextPlayer: playerRef.current.player1(),
|
||||||
|
setIsTransitioning,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[crossfadeDuration, isTransitioning, num, transitionType, volume],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOnEndedPlayer1 = useCallback(() => {
|
||||||
|
const promise = new Promise((resolve) => {
|
||||||
|
mediaAutoNext();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
promise.then(() => {
|
||||||
|
playerRef.current?.player1()?.ref?.pause();
|
||||||
|
playerRef.current?.setVolume(volume);
|
||||||
|
setIsTransitioning(false);
|
||||||
|
});
|
||||||
|
}, [mediaAutoNext, volume]);
|
||||||
|
|
||||||
|
const handleOnEndedPlayer2 = useCallback(() => {
|
||||||
|
const promise = new Promise((resolve) => {
|
||||||
|
mediaAutoNext();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
promise.then(() => {
|
||||||
|
playerRef.current?.player2()?.ref?.pause();
|
||||||
|
playerRef.current?.setVolume(volume);
|
||||||
|
setIsTransitioning(false);
|
||||||
|
});
|
||||||
|
}, [mediaAutoNext, volume]);
|
||||||
|
|
||||||
|
usePlayerEvents(
|
||||||
|
{
|
||||||
|
onPlayerSeekToTimestamp: (properties) => {
|
||||||
|
const timestamp = properties.timestamp;
|
||||||
|
const activePlayer =
|
||||||
|
num === 1 ? playerRef.current?.player1() : playerRef.current?.player2();
|
||||||
|
const wavesurfer = activePlayer?.ref;
|
||||||
|
|
||||||
|
if (wavesurfer) {
|
||||||
|
const duration = wavesurfer.getDuration();
|
||||||
|
if (duration > 0) {
|
||||||
|
// Convert timestamp to ratio (0-1) for wavesurfer
|
||||||
|
const ratio = timestamp / duration;
|
||||||
|
wavesurfer.seekTo(ratio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPlayerStatus: async (properties) => {
|
||||||
|
const status = properties.status;
|
||||||
|
if (status === PlayerStatus.PAUSED) {
|
||||||
|
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);
|
||||||
|
} else if (status === PlayerStatus.PLAYING) {
|
||||||
|
fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPlayerVolume: (properties) => {
|
||||||
|
const volume = properties.volume;
|
||||||
|
playerRef.current?.setVolume(volume);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[volume, num, isTransitioning],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (localPlayerStatus !== PlayerStatus.PLAYING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const activePlayer =
|
||||||
|
num === 1 ? playerRef.current?.player1() : playerRef.current?.player2();
|
||||||
|
const wavesurfer = activePlayer?.ref;
|
||||||
|
|
||||||
|
if (!wavesurfer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTime = wavesurfer.getCurrentTime();
|
||||||
|
|
||||||
|
if (
|
||||||
|
transitionType === PlayerStyle.CROSSFADE ||
|
||||||
|
transitionType === PlayerStyle.GAPLESS
|
||||||
|
) {
|
||||||
|
setTimestamp(Number(currentTime.toFixed(0)));
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [localPlayerStatus, num, setTimestamp, transitionType]);
|
||||||
|
|
||||||
|
useMainPlayerListener();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WaveSurferPlayerEngine
|
||||||
|
isMuted={isMuted}
|
||||||
|
isTransitioning={Boolean(isTransitioning)}
|
||||||
|
onEndedPlayer1={handleOnEndedPlayer1}
|
||||||
|
onEndedPlayer2={handleOnEndedPlayer2}
|
||||||
|
onProgressPlayer1={onProgressPlayer1}
|
||||||
|
onProgressPlayer2={onProgressPlayer2}
|
||||||
|
playerNum={num}
|
||||||
|
playerRef={playerRef}
|
||||||
|
playerStatus={localPlayerStatus}
|
||||||
|
speed={speed}
|
||||||
|
src1={player1?.streamUrl}
|
||||||
|
src2={player2?.streamUrl}
|
||||||
|
volume={volume}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function crossfadeHandler(args: {
|
||||||
|
crossfadeDuration: number;
|
||||||
|
currentPlayer: {
|
||||||
|
ref: null | WaveSurfer;
|
||||||
|
setVolume: (volume: number) => void;
|
||||||
|
};
|
||||||
|
currentPlayerNum: number;
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
isTransitioning: boolean | string;
|
||||||
|
nextPlayer: {
|
||||||
|
ref: null | WaveSurfer;
|
||||||
|
setVolume: (volume: number) => void;
|
||||||
|
};
|
||||||
|
playerNum: number;
|
||||||
|
setIsTransitioning: Dispatch<boolean | string>;
|
||||||
|
volume: number;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
crossfadeDuration,
|
||||||
|
currentPlayer,
|
||||||
|
currentPlayerNum,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
isTransitioning,
|
||||||
|
nextPlayer,
|
||||||
|
playerNum,
|
||||||
|
setIsTransitioning,
|
||||||
|
volume,
|
||||||
|
} = args;
|
||||||
|
const player = `player${playerNum}`;
|
||||||
|
|
||||||
|
if (!isTransitioning) {
|
||||||
|
if (currentTime > duration - crossfadeDuration) {
|
||||||
|
nextPlayer.setVolume(0);
|
||||||
|
nextPlayer.ref?.play();
|
||||||
|
return setIsTransitioning(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTransitioning !== player && currentPlayerNum !== playerNum) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeLeft = duration - currentTime;
|
||||||
|
|
||||||
|
// Calculate the volume levels based on time remaining
|
||||||
|
const currentPlayerVolume = (timeLeft / crossfadeDuration) * volume;
|
||||||
|
const nextPlayerVolume = ((crossfadeDuration - timeLeft) / crossfadeDuration) * volume;
|
||||||
|
|
||||||
|
// Set volumes for both players
|
||||||
|
currentPlayer.setVolume(currentPlayerVolume);
|
||||||
|
nextPlayer.setVolume(nextPlayerVolume);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gaplessHandler(args: {
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
isFlac: boolean;
|
||||||
|
isTransitioning: boolean | string;
|
||||||
|
nextPlayer: {
|
||||||
|
ref: null | WaveSurfer;
|
||||||
|
setVolume: (volume: number) => void;
|
||||||
|
};
|
||||||
|
setIsTransitioning: Dispatch<boolean | string>;
|
||||||
|
}) {
|
||||||
|
const { currentTime, duration, isFlac, isTransitioning, nextPlayer, setIsTransitioning } = args;
|
||||||
|
|
||||||
|
if (!isTransitioning) {
|
||||||
|
if (currentTime > duration - 2) {
|
||||||
|
return setIsTransitioning(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationPadding = getDurationPadding(isFlac);
|
||||||
|
|
||||||
|
if (currentTime + durationPadding >= duration) {
|
||||||
|
return nextPlayer.ref?.play().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDuration(ref: null | undefined | WaveSurfer) {
|
||||||
|
return ref?.getDuration() || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDurationPadding(isFlac: boolean) {
|
||||||
|
switch (isFlac) {
|
||||||
|
case false:
|
||||||
|
return 0.116;
|
||||||
|
case true:
|
||||||
|
return 0.065;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
usePlayerSpeed,
|
usePlayerSpeed,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import {
|
import {
|
||||||
|
BarAlign,
|
||||||
|
PlayerbarSliderType,
|
||||||
|
useGeneralSettings,
|
||||||
usePlaybackSettings,
|
usePlaybackSettings,
|
||||||
|
usePlayerbarSlider,
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useSettingsStoreActions,
|
useSettingsStoreActions,
|
||||||
} from '/@/renderer/store/settings.store';
|
} from '/@/renderer/store/settings.store';
|
||||||
@@ -31,6 +35,8 @@ export const PlayerConfig = () => {
|
|||||||
const playbackSettings = usePlaybackSettings();
|
const playbackSettings = usePlaybackSettings();
|
||||||
const { setSettings } = useSettingsStoreActions();
|
const { setSettings } = useSettingsStoreActions();
|
||||||
const speedPreservePitch = useSettingsStore((state) => state.playback.preservePitch);
|
const speedPreservePitch = useSettingsStore((state) => state.playback.preservePitch);
|
||||||
|
const playerbarSlider = usePlayerbarSlider();
|
||||||
|
const generalSettings = useGeneralSettings();
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
const formatPlaybackSpeedSliderLabel = (value: number) => {
|
const formatPlaybackSpeedSliderLabel = (value: number) => {
|
||||||
@@ -62,9 +68,14 @@ export const PlayerConfig = () => {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
id: 'queueType',
|
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
|
...(playbackSettings.type === PlayerType.WEB
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -94,7 +105,7 @@ export const PlayerConfig = () => {
|
|||||||
),
|
),
|
||||||
id: 'transitionType',
|
id: 'transitionType',
|
||||||
label: t('setting.playbackStyle', {
|
label: t('setting.playbackStyle', {
|
||||||
postProcess: 'sentenceCase',
|
postProcess: 'titleCase',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -124,36 +135,191 @@ export const PlayerConfig = () => {
|
|||||||
),
|
),
|
||||||
id: 'crossfadeDuration',
|
id: 'crossfadeDuration',
|
||||||
label: t('setting.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: (
|
component: (
|
||||||
<Switch
|
<SegmentedControl
|
||||||
defaultChecked={speedPreservePitch}
|
data={[
|
||||||
onChange={(e) => {
|
{
|
||||||
|
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({
|
setSettings({
|
||||||
playback: {
|
general: {
|
||||||
...playbackSettings,
|
...generalSettings,
|
||||||
preservePitch: e.currentTarget.checked,
|
playerbarSlider: {
|
||||||
|
...playerbarSlider,
|
||||||
|
barAlign: (value as BarAlign) || BarAlign.CENTER,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
size="sm"
|
||||||
|
value={playerbarSlider?.barAlign || BarAlign.CENTER}
|
||||||
|
w="100%"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
id: 'preservePitch',
|
id: 'barAlign',
|
||||||
label: t('setting.preservePitch', {
|
label: t('setting.playerbarWaveformAlign', {
|
||||||
postProcess: 'sentenceCase',
|
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: (
|
component: (
|
||||||
<Slider
|
<Slider
|
||||||
@@ -181,8 +347,31 @@ export const PlayerConfig = () => {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
id: 'playbackSpeed',
|
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;
|
return allOptions;
|
||||||
@@ -199,6 +388,8 @@ export const PlayerConfig = () => {
|
|||||||
setTransitionType,
|
setTransitionType,
|
||||||
crossfadeDuration,
|
crossfadeDuration,
|
||||||
setCrossfadeDuration,
|
setCrossfadeDuration,
|
||||||
|
playerbarSlider,
|
||||||
|
generalSettings,
|
||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -74,3 +74,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100%;
|
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 { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import styles from './playerbar-slider.module.css';
|
import styles from './playerbar-slider.module.css';
|
||||||
|
import { PlayerbarWaveform } from './playerbar-waveform';
|
||||||
|
|
||||||
import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
|
import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
|
||||||
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
|
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
|
||||||
@@ -14,14 +15,16 @@ import {
|
|||||||
usePlayerSong,
|
usePlayerSong,
|
||||||
usePlayerTimestamp,
|
usePlayerTimestamp,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
|
import { PlayerbarSliderType, usePlayerbarSlider } from '/@/renderer/store/settings.store';
|
||||||
import { Slider, SliderProps } from '/@/shared/components/slider/slider';
|
import { Slider, SliderProps } from '/@/shared/components/slider/slider';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
import { PlaybackSelectors } from '/@/shared/constants/playback-selectors';
|
||||||
import { PlayerType } from '/@/shared/types/types';
|
import { PlayerType } from '/@/shared/types/types';
|
||||||
|
|
||||||
export const PlayerbarSlider = ({ ...props }: SliderProps) => {
|
export const PlayerbarSlider = () => {
|
||||||
const playbackType = usePlaybackType();
|
const playbackType = usePlaybackType();
|
||||||
const currentSong = usePlayerSong();
|
const currentSong = usePlayerSong();
|
||||||
|
const playerbarSlider = usePlayerbarSlider();
|
||||||
|
|
||||||
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
|
const songDuration = currentSong?.duration ? currentSong.duration / 1000 : 0;
|
||||||
const [isSeeking, setIsSeeking] = useState(false);
|
const [isSeeking, setIsSeeking] = useState(false);
|
||||||
@@ -52,6 +55,8 @@ export const PlayerbarSlider = ({ ...props }: SliderProps) => {
|
|||||||
|
|
||||||
useRemote();
|
useRemote();
|
||||||
|
|
||||||
|
const isWaveform = playerbarSlider?.type === PlayerbarSliderType.WAVEFORM;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.sliderContainer}>
|
<div className={styles.sliderContainer}>
|
||||||
@@ -68,38 +73,40 @@ export const PlayerbarSlider = ({ ...props }: SliderProps) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.sliderWrapper}>
|
<div className={styles.sliderWrapper}>
|
||||||
<CustomPlayerbarSlider
|
{isWaveform ? (
|
||||||
{...props}
|
<PlayerbarWaveform />
|
||||||
label={(value) => formatDuration(value * 1000)}
|
) : (
|
||||||
max={songDuration}
|
<CustomPlayerbarSlider
|
||||||
min={0}
|
label={(value) => formatDuration(value * 1000)}
|
||||||
onChange={(e) => {
|
max={songDuration}
|
||||||
// Cancel any pending timeout if user starts seeking again
|
min={0}
|
||||||
if (seekTimeoutRef.current) {
|
onChange={(e) => {
|
||||||
clearTimeout(seekTimeoutRef.current);
|
// Cancel any pending timeout if user starts seeking again
|
||||||
seekTimeoutRef.current = null;
|
if (seekTimeoutRef.current) {
|
||||||
}
|
clearTimeout(seekTimeoutRef.current);
|
||||||
setIsSeeking(true);
|
seekTimeoutRef.current = null;
|
||||||
setSeekValue(e);
|
}
|
||||||
}}
|
setIsSeeking(true);
|
||||||
onChangeEnd={(e) => {
|
setSeekValue(e);
|
||||||
setSeekValue(e);
|
}}
|
||||||
handleSeekToTimestamp(e);
|
onChangeEnd={(e) => {
|
||||||
|
setSeekValue(e);
|
||||||
|
handleSeekToTimestamp(e);
|
||||||
|
|
||||||
// Delay resetting isSeeking to allow currentTime to catch up
|
// Delay resetting isSeeking to allow currentTime to catch up
|
||||||
// This prevents the slider from flickering back and forth
|
seekTimeoutRef.current = setTimeout(() => {
|
||||||
seekTimeoutRef.current = setTimeout(() => {
|
setIsSeeking(false);
|
||||||
setIsSeeking(false);
|
seekTimeoutRef.current = null;
|
||||||
seekTimeoutRef.current = null;
|
}, 300);
|
||||||
}, 300);
|
}}
|
||||||
}}
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e?.stopPropagation();
|
||||||
e?.stopPropagation();
|
}}
|
||||||
}}
|
size={6}
|
||||||
size={6}
|
value={!isSeeking ? currentTime : seekValue}
|
||||||
value={!isSeeking ? currentTime : seekValue}
|
w="100%"
|
||||||
w="100%"
|
/>
|
||||||
/>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.sliderValueWrapper}>
|
<div className={styles.sliderValueWrapper}>
|
||||||
<Text
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import { GridConfig } from '/@/renderer/features/shared/components/grid-config';
|
|||||||
import { SettingsButton } from '/@/renderer/features/shared/components/settings-button';
|
import { SettingsButton } from '/@/renderer/features/shared/components/settings-button';
|
||||||
import { TableConfig } from '/@/renderer/features/shared/components/table-config';
|
import { TableConfig } from '/@/renderer/features/shared/components/table-config';
|
||||||
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store';
|
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store';
|
||||||
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
import { Modal } from '/@/shared/components/modal/modal';
|
import { Modal } from '/@/shared/components/modal/modal';
|
||||||
@@ -196,7 +197,7 @@ const Config = ({
|
|||||||
export const ListConfigTable = ({
|
export const ListConfigTable = ({
|
||||||
options,
|
options,
|
||||||
}: {
|
}: {
|
||||||
options: { component: ReactNode; id: string; label: ReactNode | string }[];
|
options: { component: ReactNode; id: string; isDivider?: boolean; label: ReactNode | string }[];
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
@@ -208,12 +209,23 @@ export const ListConfigTable = ({
|
|||||||
withTableBorder={false}
|
withTableBorder={false}
|
||||||
>
|
>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{options.map((option) => (
|
{options.map((option) => {
|
||||||
<Table.Tr key={option.id}>
|
if (option.isDivider) {
|
||||||
<Table.Th w="50%">{option.label}</Table.Th>
|
return (
|
||||||
<Table.Td p={0}>{option.component}</Table.Td>
|
<Table.Tr key={option.id}>
|
||||||
</Table.Tr>
|
<Table.Td colSpan={2} px={0} py="md">
|
||||||
))}
|
<Divider />
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Table.Tr key={option.id}>
|
||||||
|
<Table.Th w="50%">{option.label}</Table.Th>
|
||||||
|
<Table.Td p={0}>{option.component}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -202,6 +202,18 @@ const SkipButtonsSchema = z.object({
|
|||||||
skipForwardSeconds: z.number(),
|
skipForwardSeconds: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const PlayerbarSliderTypeSchema = z.enum(['slider', 'waveform']);
|
||||||
|
|
||||||
|
const BarAlignSchema = z.enum(['top', 'bottom', 'center']);
|
||||||
|
|
||||||
|
const PlayerbarSliderSchema = z.object({
|
||||||
|
barAlign: BarAlignSchema,
|
||||||
|
barGap: z.number(),
|
||||||
|
barRadius: z.number(),
|
||||||
|
barWidth: z.number(),
|
||||||
|
type: PlayerbarSliderTypeSchema,
|
||||||
|
});
|
||||||
|
|
||||||
const GeneralSettingsSchema = z.object({
|
const GeneralSettingsSchema = z.object({
|
||||||
accent: z
|
accent: z
|
||||||
.string()
|
.string()
|
||||||
@@ -233,6 +245,7 @@ const GeneralSettingsSchema = z.object({
|
|||||||
passwordStore: z.string().optional(),
|
passwordStore: z.string().optional(),
|
||||||
playButtonBehavior: z.nativeEnum(Play),
|
playButtonBehavior: z.nativeEnum(Play),
|
||||||
playerbarOpenDrawer: z.boolean(),
|
playerbarOpenDrawer: z.boolean(),
|
||||||
|
playerbarSlider: PlayerbarSliderSchema,
|
||||||
resume: z.boolean(),
|
resume: z.boolean(),
|
||||||
showQueueDrawerButton: z.boolean(),
|
showQueueDrawerButton: z.boolean(),
|
||||||
sidebarCollapsedNavigation: z.boolean(),
|
sidebarCollapsedNavigation: z.boolean(),
|
||||||
@@ -365,6 +378,12 @@ export enum ArtistItem {
|
|||||||
TOP_SONGS = 'topSongs',
|
TOP_SONGS = 'topSongs',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum BarAlign {
|
||||||
|
BOTTOM = 'bottom',
|
||||||
|
CENTER = 'center',
|
||||||
|
TOP = 'top',
|
||||||
|
}
|
||||||
|
|
||||||
export enum BindingActions {
|
export enum BindingActions {
|
||||||
BROWSER_BACK = 'browserBack',
|
BROWSER_BACK = 'browserBack',
|
||||||
BROWSER_FORWARD = 'browserForward',
|
BROWSER_FORWARD = 'browserForward',
|
||||||
@@ -428,6 +447,11 @@ export enum HomeItem {
|
|||||||
RECENTLY_RELEASED = 'recentlyReleased',
|
RECENTLY_RELEASED = 'recentlyReleased',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum PlayerbarSliderType {
|
||||||
|
SLIDER = 'slider',
|
||||||
|
WAVEFORM = 'waveform',
|
||||||
|
}
|
||||||
|
|
||||||
export type DataGridProps = {
|
export type DataGridProps = {
|
||||||
itemGap: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
|
itemGap: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
|
||||||
itemsPerRow: number;
|
itemsPerRow: number;
|
||||||
@@ -596,6 +620,13 @@ const initialState: SettingsState = {
|
|||||||
passwordStore: undefined,
|
passwordStore: undefined,
|
||||||
playButtonBehavior: Play.NOW,
|
playButtonBehavior: Play.NOW,
|
||||||
playerbarOpenDrawer: false,
|
playerbarOpenDrawer: false,
|
||||||
|
playerbarSlider: {
|
||||||
|
barAlign: BarAlign.CENTER,
|
||||||
|
barGap: 1,
|
||||||
|
barRadius: 4,
|
||||||
|
barWidth: 2,
|
||||||
|
type: PlayerbarSliderType.WAVEFORM,
|
||||||
|
},
|
||||||
resume: true,
|
resume: true,
|
||||||
showQueueDrawerButton: false,
|
showQueueDrawerButton: false,
|
||||||
sidebarCollapsedNavigation: true,
|
sidebarCollapsedNavigation: true,
|
||||||
@@ -1303,3 +1334,7 @@ export const useListSettings = (type: ItemListKey) =>
|
|||||||
(state) => state.lists[type as keyof typeof state.lists],
|
(state) => state.lists[type as keyof typeof state.lists],
|
||||||
shallow,
|
shallow,
|
||||||
) as ItemListSettings;
|
) as ItemListSettings;
|
||||||
|
|
||||||
|
export const usePrimaryColor = () => useSettingsStore((store) => store.general.accent);
|
||||||
|
|
||||||
|
export const usePlayerbarSlider = () => useSettingsStore((store) => store.general.playerbarSlider);
|
||||||
|
|||||||
@@ -224,3 +224,9 @@ export const useSetColorScheme = () => {
|
|||||||
|
|
||||||
return { setColorScheme };
|
return { setColorScheme };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useColorScheme = () => {
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
|
return colorScheme === 'dark' ? 'dark' : 'light';
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user