diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 6d72976ab..bff8e0a53 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -62,7 +62,8 @@ export const App = () => { if (!isRunning) { const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters; - const properties = { + const properties: Record = { + speed: usePlayerStore.getState().current.speed, ...getMpvProperties(useSettingsStore.getState().playback.mpvProperties), }; diff --git a/src/renderer/components/audio-player/index.tsx b/src/renderer/components/audio-player/index.tsx index 541620779..7f8b19044 100644 --- a/src/renderer/components/audio-player/index.tsx +++ b/src/renderer/components/audio-player/index.tsx @@ -10,6 +10,7 @@ import { import { useSettingsStore } from '/@/renderer/store/settings.store'; import type { CrossfadeStyle } from '/@/renderer/types'; import { PlaybackStyle, PlayerStatus } from '/@/renderer/types'; +import { useSpeed } from '/@/renderer/store'; interface AudioPlayerProps extends ReactPlayerProps { crossfadeDuration: number; @@ -59,6 +60,7 @@ export const AudioPlayer = forwardRef( const [isTransitioning, setIsTransitioning] = useState(false); const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId); const playback = useSettingsStore((state) => state.playback.mpvProperties); + const playbackSpeed = useSpeed(); const [webAudio, setWebAudio] = useState(null); const [player1Source, setPlayer1Source] = useState( @@ -307,6 +309,7 @@ export const AudioPlayer = forwardRef( }} height={0} muted={muted} + playbackRate={playbackSpeed} playing={currentPlayer === 1 && status === PlayerStatus.PLAYING} progressInterval={isTransitioning ? 10 : 250} url={player1?.streamUrl} @@ -325,6 +328,7 @@ export const AudioPlayer = forwardRef( }} height={0} muted={muted} + playbackRate={playbackSpeed} playing={currentPlayer === 2 && status === PlayerStatus.PLAYING} progressInterval={isTransitioning ? 10 : 250} url={player2?.streamUrl} diff --git a/src/renderer/features/player/components/player-button.tsx b/src/renderer/features/player/components/player-button.tsx index d64c9a9e3..f042bb5b6 100644 --- a/src/renderer/features/player/components/player-button.tsx +++ b/src/renderer/features/player/components/player-button.tsx @@ -103,6 +103,7 @@ const StyledPlayerButton = styled(UnstyledButton)` } &:hover { + color: var(--playerbar-btn-fg-hover); background: var(--playerbar-btn-bg-hover); svg { diff --git a/src/renderer/features/player/components/right-controls.tsx b/src/renderer/features/player/components/right-controls.tsx index 9221f3b94..b3402b7e0 100644 --- a/src/renderer/features/player/components/right-controls.tsx +++ b/src/renderer/features/player/components/right-controls.tsx @@ -17,18 +17,21 @@ import { useHotkeySettings, useMuted, useSidebarStore, + useSpeed, useVolume, } from '/@/renderer/store'; import { useRightControls } from '../hooks/use-right-controls'; import { PlayerButton } from './player-button'; import { LibraryItem, ServerType, Song } from '/@/renderer/api/types'; import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared'; -import { Rating } from '/@/renderer/components'; +import { DropdownMenu, Rating } from '/@/renderer/components'; import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider'; const ipc = isElectron() ? window.electron.ipc : null; const remote = isElectron() ? window.electron.remote : null; +const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]; + export const RightControls = () => { const isMinWidth = useMediaQuery('(max-width: 480px)'); const volume = useVolume(); @@ -38,8 +41,16 @@ export const RightControls = () => { const { setSideBar } = useAppStoreActions(); const { rightExpanded: isQueueExpanded } = useSidebarStore(); const { bindings } = useHotkeySettings(); - const { handleVolumeSlider, handleVolumeWheel, handleMute, handleVolumeDown, handleVolumeUp } = - useRightControls(); + const { + handleVolumeSlider, + handleVolumeWheel, + handleMute, + handleVolumeDown, + handleVolumeUp, + handleSpeed, + } = useRightControls(); + + const speed = useSpeed(); const updateRatingMutation = useSetRating({}); const addToFavoritesMutation = useCreateFavorite({}); @@ -184,6 +195,28 @@ export const RightControls = () => { align="center" spacing="xs" > + + + {speed} x} + tooltip={{ + label: 'Playback speed', + openDelay: 500, + }} + variant="secondary" + /> + + + {PLAYBACK_SPEEDS.map((speed) => ( + handleSpeed(Number(speed))} + > + {speed} + + ))} + + { variant="secondary" onClick={handleToggleFavorite} /> - } - tooltip={{ label: 'View queue', openDelay: 500 }} - variant="secondary" - onClick={handleToggleQueue} - /> + {!isMinWidth ? ( + } + tooltip={{ label: 'View queue', openDelay: 500 }} + variant="secondary" + onClick={handleToggleQueue} + /> + ) : null} { const volume = useVolume(); const muted = useMuted(); const { volumeWheelStep } = useGeneralSettings(); + const speed = useSpeed(); + const setCurrentSpeed = useSetCurrentSpeed(); // Ensure that the mpv player volume is set on startup useEffect(() => { @@ -44,6 +52,7 @@ export const useRightControls = () => { if (mpvPlayer) { mpvPlayer.volume(volume); + mpvPlayer.setProperties({ speed }); if (muted) { mpvPlayer.mute(muted); @@ -53,6 +62,16 @@ export const useRightControls = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const handleSpeed = useCallback( + (e: number) => { + setCurrentSpeed(e); + if (mpvPlayer) { + mpvPlayer?.setProperties({ speed: e }); + } + }, + [setCurrentSpeed], + ); + const handleVolumeSlider = (e: number) => { mpvPlayer?.volume(e); remote?.updateVolume(e); @@ -123,6 +142,7 @@ export const useRightControls = () => { return { handleMute, + handleSpeed, handleVolumeDown, handleVolumeSlider, handleVolumeSliderState, diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index dcbf190af..e0bee79be 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -18,6 +18,7 @@ export interface PlayerState { seek: boolean; shuffledIndex: number; song?: QueueSong; + speed: number; status: PlayerStatus; time: number; }; @@ -81,6 +82,7 @@ export interface PlayerSlice extends PlayerState { reorderQueue: (rowUniqueIds: string[], afterUniqueId?: string) => PlayerData; restoreQueue: (data: Partial) => PlayerData; setCurrentIndex: (index: number) => PlayerData; + setCurrentSpeed: (speed: number) => void; setCurrentTime: (time: number, seek?: boolean) => void; setCurrentTrack: (uniqueId: string) => PlayerData; setFavorite: (ids: string[], favorite: boolean) => string[]; @@ -739,6 +741,11 @@ export const usePlayerStore = create()( return get().actions.getPlayerData(); }, + setCurrentSpeed: (speed) => { + set((state) => { + state.current.speed = speed; + }); + }, setCurrentTime: (time, seek = false) => { set((state) => { state.current.seek = seek; @@ -913,6 +920,7 @@ export const usePlayerStore = create()( seek: false, shuffledIndex: 0, song: {} as QueueSong, + speed: 1.0, status: PlayerStatus.PAUSED, time: 0, }, @@ -1027,6 +1035,10 @@ export const useVolume = () => usePlayerStore((state) => state.volume); export const useMuted = () => usePlayerStore((state) => state.muted); +export const useSpeed = () => usePlayerStore((state) => state.current.speed); + +export const useSetCurrentSpeed = () => usePlayerStore((state) => state.actions.setCurrentSpeed); + export const useSetQueueFavorite = () => usePlayerStore((state) => state.actions.setFavorite); export const useSetQueueRating = () => usePlayerStore((state) => state.actions.setRating); diff --git a/src/renderer/themes/default.scss b/src/renderer/themes/default.scss index c8c64b5f0..11b0f5b4e 100644 --- a/src/renderer/themes/default.scss +++ b/src/renderer/themes/default.scss @@ -70,7 +70,7 @@ --input-placeholder-fg: rgb(107, 108, 109); --input-active-fg: rgb(193, 193, 193); --input-active-bg: rgba(255, 255, 255, 10%); - --dropdown-menu-bg: rgb(32, 32, 32); + --dropdown-menu-bg: rgba(32, 32, 32, 95%); --dropdown-menu-fg: rgb(235, 235, 235); --dropdown-menu-item-padding: 0.8rem; --dropdown-menu-item-font-size: 1rem;