فهرست منبع

feat: keyboard commands to skip tracks

Fela Maslen 5 سال پیش
والد
کامیت
192f46ed24

+ 21 - 2
gmus-web/src/components/app.tsx

@@ -37,13 +37,27 @@ export const App: React.FC<Props> = ({ socket }) => {
     [dispatch],
   );
 
-  const { onNext } = usePlayQueue();
+  const { onNext, onPrev, loading: loadingQueue } = usePlayQueue();
   const onEnded = useCallback(() => {
     if (state.player.songId) {
       onNext(state.player.songId);
     }
   }, [onNext, state.player.songId]);
 
+  const nextSong = useCallback(() => {
+    if (loadingQueue || !state.player.songId) {
+      return;
+    }
+    onNext(state.player.songId);
+  }, [loadingQueue, onNext, state.player.songId]);
+
+  const prevSong = useCallback(() => {
+    if (loadingQueue || !state.player.songId) {
+      return;
+    }
+    onPrev(state.player.songId);
+  }, [loadingQueue, onPrev, state.player.songId]);
+
   return (
     <>
       {isMaster(state) && !!state.player.songId && (
@@ -58,7 +72,12 @@ export const App: React.FC<Props> = ({ socket }) => {
       )}
       <StateInspector name="ui">
         <Suspense fallback={<LoadingWrapper />}>
-          <UI isMaster={isMaster(state)} currentSong={currentSong} />
+          <UI
+            isMaster={isMaster(state)}
+            currentSong={currentSong}
+            nextSong={nextSong}
+            prevSong={prevSong}
+          />
         </Suspense>
       </StateInspector>
     </>

+ 26 - 0
gmus-web/src/components/ui/cmus/reducer/keypress.spec.ts

@@ -43,6 +43,19 @@ describe(ActionTypeKeyPressed, () => {
     });
   });
 
+  describe(Keys.B, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.B };
+
+    it('should set skip delta to 1', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.skipSong).toStrictEqual({
+        delta: 1,
+        serialNumber: 1,
+      });
+    });
+  });
+
   describe(Keys.C, () => {
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.C };
 
@@ -287,6 +300,19 @@ describe(ActionTypeKeyPressed, () => {
     });
   });
 
+  describe(Keys.Z, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.Z };
+
+    it('should set skip delta to -1', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.skipSong).toStrictEqual({
+        delta: -1,
+        serialNumber: 1,
+      });
+    });
+  });
+
   describe(Keys.pageDown, () => {
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.pageDown };
 

+ 6 - 0
gmus-web/src/components/ui/cmus/reducer/keypress.ts

@@ -97,6 +97,12 @@ export function handleKeyPress(state: CmusUIState, action: ActionKeyPressed): Cm
     case Keys.question:
       return { ...state, overlay: Overlay.Help };
 
+    case Keys.B:
+      return { ...state, skipSong: { delta: 1, serialNumber: state.skipSong.serialNumber + 1 } };
+
+    case Keys.Z:
+      return { ...state, skipSong: { delta: -1, serialNumber: state.skipSong.serialNumber + 1 } };
+
     case Keys.C:
       return withGlobalAction(state, playPaused());
 

+ 1 - 0
gmus-web/src/components/ui/cmus/reducer/reducer.ts

@@ -13,6 +13,7 @@ export const initialCmusUIState: CmusUIState = {
   globalAction: null,
   globalActionSerialNumber: 0,
   scroll: { delta: 0, serialNumber: 0 },
+  skipSong: { delta: 0, serialNumber: 0 },
   view: View.Library,
   commandMode: false,
   overlay: null,

+ 4 - 0
gmus-web/src/components/ui/cmus/types.ts

@@ -22,6 +22,10 @@ export type CmusUIState = {
     delta: number;
     serialNumber: number;
   };
+  skipSong: {
+    delta: 0 | 1 | -1;
+    serialNumber: number;
+  };
   view: View;
   commandMode: boolean;
   overlay: Overlay | null;

+ 2 - 0
gmus-web/src/components/ui/cmus/views/help.tsx

@@ -7,7 +7,9 @@ import * as Styled from './help.styles';
 type Command = { command: string; description: string };
 
 const commandsGeneral: Command[] = [
+  { command: 'z', description: 'previous song' },
   { command: 'c', description: 'play / pause' },
+  { command: 'b', description: 'next song' },
   { command: 'j', description: 'select next list item' },
   { command: 'k', description: 'select previous list item' },
   { command: '<PageDown>', description: 'select next page of list items' },

+ 15 - 2
gmus-web/src/components/ui/cmus/wrapper.tsx

@@ -1,4 +1,4 @@
-import React, { useContext, useEffect } from 'react';
+import React, { useContext, useEffect, useRef } from 'react';
 import { useReducer } from 'reinspect';
 
 import { DispatchContext } from '../../../context/state';
@@ -25,7 +25,7 @@ import * as Styled from './wrapper.styles';
 
 const viewTitles = Object.values(View);
 
-export const CmusUIProvider: UIProviderComponent = ({ currentSong }) => {
+export const CmusUIProvider: UIProviderComponent = ({ currentSong, nextSong, prevSong }) => {
   const dispatch = useContext(DispatchContext);
   const [stateUI, dispatchUI] = useReducer(cmusUIReducer, initialCmusUIState, init, 'ui');
 
@@ -35,6 +35,19 @@ export const CmusUIProvider: UIProviderComponent = ({ currentSong }) => {
     }
   }, [dispatch, stateUI.globalAction, stateUI.globalActionSerialNumber]);
 
+  const lastSkipSerialNumber = useRef<number>(0);
+  useEffect(() => {
+    if (lastSkipSerialNumber.current !== stateUI.skipSong.serialNumber) {
+      lastSkipSerialNumber.current = stateUI.skipSong.serialNumber;
+
+      if (stateUI.skipSong.delta === 1) {
+        nextSong();
+      } else if (stateUI.skipSong.delta === -1) {
+        prevSong();
+      }
+    }
+  }, [stateUI.skipSong, nextSong, prevSong]);
+
   useVimBindings(dispatchUI, stateUI.commandMode);
 
   useLibrary(stateUI, dispatchUI);

+ 2 - 0
gmus-web/src/components/ui/types.ts

@@ -9,6 +9,8 @@ export enum UIProvider {
 export type UIProps = {
   isMaster: boolean;
   currentSong: Song | null;
+  nextSong: () => void;
+  prevSong: () => void;
 };
 
 export type UIProviderComponent = React.FC<UIProps>;

+ 21 - 16
gmus-web/src/hooks/queue.ts

@@ -1,6 +1,7 @@
+import { useThrottleCallback } from '@react-hook/throttle';
 import { AxiosInstance, AxiosResponse } from 'axios';
-import { useCallback, useContext, useEffect } from 'react';
-import { stateSet } from '../actions';
+import { Dispatch, useCallback, useContext, useEffect } from 'react';
+import { LocalAction, stateSet } from '../actions';
 import { DispatchContext } from '../context/state';
 import { NullSong, Song, songExists } from '../types';
 import { getApiUrl } from '../utils/url';
@@ -9,29 +10,18 @@ import { useRequestCallback } from './request';
 
 function useNextOrPrevSong(
   key: 'next' | 'prev',
-): [(songId: number) => void, Song | NullSong | null, boolean] {
+  dispatch: Dispatch<LocalAction>,
+): [(songId: number) => void, boolean] {
   const sendRequest = useCallback(
     (axios: AxiosInstance, id: number): Promise<AxiosResponse<Song | NullSong>> =>
       axios.get(`${getApiUrl()}/${key}-song?id=${id}`),
     [key],
   );
 
-  return useRequestCallback<number, Song | NullSong>({
+  const [onRequest, response, loading] = useRequestCallback<number, Song | NullSong>({
     sendRequest,
   });
-}
-
-export function usePlayQueue(): {
-  onNext: (songId: number) => void;
-  onPrev: (songId: number) => void;
-  loading: boolean;
-} {
-  const dispatch = useContext(DispatchContext);
 
-  const [onRequestNext, nextSong, loadingNext] = useNextOrPrevSong('next');
-  const [onRequestPrev, prevSong, loadingPrev] = useNextOrPrevSong('prev');
-
-  const response = nextSong ?? prevSong;
   useEffect(() => {
     if (response) {
       if (songExists(response)) {
@@ -42,5 +32,20 @@ export function usePlayQueue(): {
     }
   }, [dispatch, response]);
 
+  const debouncedRequest = useThrottleCallback(onRequest, 5, true);
+
+  return [debouncedRequest, loading];
+}
+
+export function usePlayQueue(): {
+  onNext: (songId: number) => void;
+  onPrev: (songId: number) => void;
+  loading: boolean;
+} {
+  const dispatch = useContext(DispatchContext);
+
+  const [onRequestNext, loadingNext] = useNextOrPrevSong('next', dispatch);
+  const [onRequestPrev, loadingPrev] = useNextOrPrevSong('prev', dispatch);
+
   return { onNext: onRequestNext, onPrev: onRequestPrev, loading: loadingNext || loadingPrev };
 }

+ 2 - 0
gmus-web/src/hooks/vim.ts

@@ -12,9 +12,11 @@ export const Keys = {
   pageUp: 'PageUp',
   '1': '1',
   '2': '2',
+  B: 'b',
   C: 'c',
   J: 'j',
   K: 'k',
+  Z: 'z',
 };
 
 const availableKeys = Object.values(Keys);