Procházet zdrojové kódy

feat: search within loaded artists and songs

    * fix: keep library rendered to preserve scroll position on different modes
    * fix: backspace and only enter words in search
    * feat: search artists and visible songs
Fela Maslen před 5 roky
rodič
revize
1625743a26

+ 9 - 0
gmus-web/src/components/ui/cmus/actions.ts

@@ -9,6 +9,7 @@ export enum CmusUIActionType {
   CommandSet = '@@ui/cmus/COMMAND_SET',
   ClientActivated = '@@ui/cmus/CLIENT_ACTIVATED',
   QueueInfoLoaded = '@@ui/cmus/QUEUE_INFO_LOADED',
+  Searched = '@@ui/cmus/SEARCHED',
 }
 
 export type ArtistsSet = Action<CmusUIActionType.ArtistsSet, string[]>;
@@ -65,6 +66,13 @@ export const queueInfoLoaded = (songs: Song[]): QueueInfoLoaded => ({
   payload: songs,
 });
 
+export type Searched = Action<CmusUIActionType.Searched, string | null>;
+
+export const searched = (term: string | null): Searched => ({
+  type: CmusUIActionType.Searched,
+  payload: term,
+});
+
 export type CmusUIAction =
   | ArtistsSet
   | ArtistAlbumsLoaded
@@ -72,4 +80,5 @@ export type CmusUIAction =
   | CommandSet
   | ClientActivated
   | QueueInfoLoaded
+  | Searched
   | ActionKeyPressed;

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

@@ -51,3 +51,5 @@ export const stateQueue: CmusUIState = {
     active: null,
   },
 };
+
+export const stateSearching: CmusUIState = { ...initialCmusUIState, searchMode: true };

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

@@ -106,6 +106,9 @@ export function handleKeyPress(state: CmusUIState, action: ActionKeyPressed): Cm
     case Keys.esc:
       return { ...state, overlay: null };
 
+    case Keys.slash:
+      return { ...state, searchMode: true };
+
     case Keys.question:
       return { ...state, overlay: Overlay.Help };
 

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

@@ -8,6 +8,7 @@ import { CmusUIState, LibraryModeWindow, View } from '../types';
 import { setArtistAlbums, setArtists, setArtistSongs } from './artists';
 import { onCommand } from './command';
 import { handleKeyPress } from './keypress';
+import { handleSearch } from './search';
 
 export const initialCmusUIState: CmusUIState = {
   globalAction: null,
@@ -16,6 +17,7 @@ export const initialCmusUIState: CmusUIState = {
   skipSong: { delta: 0, serialNumber: 0 },
   view: View.Library,
   commandMode: false,
+  searchMode: false,
   overlay: null,
   artists: [],
   artistAlbums: {},
@@ -26,7 +28,6 @@ export const initialCmusUIState: CmusUIState = {
     activeArtist: null,
     activeAlbum: null,
     activeSongId: null,
-    visibleSongs: [],
   },
   clientList: {
     active: null,
@@ -55,6 +56,9 @@ export function cmusUIReducer(state: CmusUIState, action: CmusUIAction): CmusUIS
     case CmusUIActionType.CommandSet:
       return onCommand(state, action);
 
+    case CmusUIActionType.Searched:
+      return handleSearch(state, action);
+
     case CmusUIActionType.ClientActivated:
       return { ...state, clientList: { active: action.payload } };
 

+ 73 - 0
gmus-web/src/components/ui/cmus/reducer/search.spec.ts

@@ -0,0 +1,73 @@
+import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../../hooks/vim';
+import { Song } from '../../../../types';
+import { CmusUIActionType, searched } from '../actions';
+import { CmusUIState, LibraryModeWindow, View } from '../types';
+import { stateSearching } from './fixtures';
+import { cmusUIReducer, initialCmusUIState } from './reducer';
+
+describe('Searching', () => {
+  const actionToSearch: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.slash };
+
+  describe(Keys.slash, () => {
+    it('should set searchMode to true', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, actionToSearch);
+      expect(result.searchMode).toBe(true);
+    });
+  });
+
+  describe(CmusUIActionType.Searched, () => {
+    const stateSearchingArtists: CmusUIState = {
+      ...stateSearching,
+      view: View.Library,
+      artists: ['Amy Winehouse', 'Anticon', 'Bach'],
+      library: {
+        ...stateSearching.library,
+        activeArtist: null,
+        modeWindow: LibraryModeWindow.ArtistList,
+      },
+    };
+
+    const stateSearchingSongs: CmusUIState = {
+      ...stateSearching,
+      view: View.Library,
+      artists: ['Amy Winehouse'],
+      artistSongs: {
+        'Amy Winehouse': [
+          { id: 184, title: 'Rehab' } as Song,
+          { id: 883, title: 'Wake Up Alone' } as Song,
+        ],
+      },
+      library: {
+        ...stateSearching.library,
+        activeArtist: 'Amy Winehouse',
+        activeSongId: null,
+        modeWindow: LibraryModeWindow.SongList,
+      },
+    };
+
+    describe('artists', () => {
+      it('should select the first match', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(stateSearchingArtists, searched('ant'));
+        expect(result.library.activeArtist).toBe('Anticon');
+      });
+    });
+
+    describe('songs', () => {
+      it('should select the first match (by title)', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(stateSearchingSongs, searched('w'));
+        expect(result.library.activeSongId).toBe(883);
+      });
+    });
+
+    describe('when finishing / cancelling', () => {
+      it('should set searchMode to false', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(stateSearchingArtists, searched(null));
+        expect(result.searchMode).toBe(false);
+      });
+    });
+  });
+});

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

@@ -0,0 +1,50 @@
+import { Searched } from '../actions';
+import { CmusUIState, LibraryModeWindow, View } from '../types';
+
+function searchForArtist(state: CmusUIState, term: string): CmusUIState {
+  const closestArtist = state.artists.find((compare) => compare.toLowerCase().startsWith(term));
+  if (!closestArtist) {
+    return state;
+  }
+
+  return { ...state, library: { ...state.library, activeArtist: closestArtist } };
+}
+
+function searchForSong(state: CmusUIState, term: string): CmusUIState {
+  if (!state.library.activeArtist) {
+    return state;
+  }
+  const songsByArtist = state.artistSongs[state.library.activeArtist] ?? [];
+  const filteredSongs = state.library.activeAlbum
+    ? songsByArtist.filter(({ album }) => album === state.library.activeAlbum)
+    : songsByArtist;
+
+  const closestSong = filteredSongs.find((compare) => compare.title.toLowerCase().startsWith(term));
+  if (!closestSong) {
+    return state;
+  }
+
+  return { ...state, library: { ...state.library, activeSongId: closestSong.id } };
+}
+
+export function handleSearch(state: CmusUIState, action: Searched): CmusUIState {
+  if (state.view !== View.Library) {
+    return state;
+  }
+  if (action.payload === null) {
+    return { ...state, searchMode: false };
+  }
+  if (!action.payload.length) {
+    return state;
+  }
+  const termLower = action.payload?.toLowerCase() ?? '';
+  switch (state.library.modeWindow) {
+    case LibraryModeWindow.ArtistList:
+      return searchForArtist(state, termLower);
+    case LibraryModeWindow.SongList:
+      return searchForSong(state, termLower);
+
+    default:
+      return state;
+  }
+}

+ 32 - 0
gmus-web/src/components/ui/cmus/search.tsx

@@ -0,0 +1,32 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { Keys } from '../../../hooks/vim';
+import { searched } from './actions';
+import { CmusUIDispatchContext } from './reducer';
+
+export const Search: React.FC = () => {
+  const dispatchUI = useContext(CmusUIDispatchContext);
+  const [term, setTerm] = useState<string>('');
+
+  useEffect(() => {
+    dispatchUI(searched(term));
+  }, [dispatchUI, term]);
+
+  useEffect(() => {
+    const listener = (event: KeyboardEvent): void => {
+      if (event.key === Keys.enter || event.key === Keys.esc) {
+        dispatchUI(searched(null));
+      } else if (event.key === 'Backspace') {
+        setTerm((last) => last.substring(0, last.length - 1));
+      } else if (/^\w$/.test(event.key)) {
+        setTerm((last) => `${last}${event.key}`);
+      }
+    };
+
+    window.addEventListener('keydown', listener);
+    return (): void => {
+      window.removeEventListener('keydown', listener);
+    };
+  }, [dispatchUI]);
+
+  return <div>/{term}</div>;
+};

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

@@ -29,6 +29,7 @@ export type CmusUIState = {
   };
   view: View;
   commandMode: boolean;
+  searchMode: boolean;
   overlay: Overlay | null;
   artists: string[];
   artistAlbums: Record<string, string[]>;
@@ -39,7 +40,6 @@ export type CmusUIState = {
     activeAlbum: string | null;
     activeSongId: number | null;
     modeWindow: LibraryModeWindow;
-    visibleSongs: Song[];
   };
   clientList: {
     active: string | null;

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

@@ -25,6 +25,7 @@ const commandsLibrary: Command[] = [
   { command: '<Tab>', description: 'switch between artists/albums and songs' },
   { command: '<Space>', description: 'toggle albums for selected artist' },
   { command: '<Enter>', description: 'play the selected song' },
+  { command: '/', description: 'search for an item' },
 ];
 
 const commandsClientList: Command[] = [

+ 11 - 0
gmus-web/src/components/ui/cmus/views/library.styles.ts

@@ -0,0 +1,11 @@
+import { CSSProperties } from 'react';
+import styled from 'styled-components';
+import { FlexRow } from '../styled/layout';
+
+export type ContainerProps = { hidden: boolean };
+
+export const Container = styled(FlexRow)<ContainerProps>`
+  display: ${({ hidden }): CSSProperties['display'] => (hidden ? 'none' : 'flex')};
+  flex: 1;
+  height: 100%;
+`;

+ 5 - 4
gmus-web/src/components/ui/cmus/views/library.tsx

@@ -4,20 +4,21 @@ import { CmusUIStateContext } from '../reducer';
 import { LibraryModeWindow } from '../types';
 
 import { Artists, Props as PropsArtists } from './artists';
+import * as Styled from './library.styles';
 import { Songs } from './songs';
 
-export type Props = Pick<PropsArtists, 'currentArtist'>;
+export type Props = Pick<PropsArtists, 'currentArtist'> & Styled.ContainerProps;
 
-export const ViewLibrary: React.FC<Props> = ({ currentArtist }) => {
+export const ViewLibrary: React.FC<Props> = ({ hidden, currentArtist }) => {
   const { library } = useContext(CmusUIStateContext);
 
   return (
-    <>
+    <Styled.Container hidden={hidden}>
       <Artists
         active={library.modeWindow === LibraryModeWindow.ArtistList}
         currentArtist={currentArtist}
       />
       <Songs active={library.modeWindow === LibraryModeWindow.SongList} />
-    </>
+    </Styled.Container>
   );
 };

+ 6 - 4
gmus-web/src/components/ui/cmus/wrapper.tsx

@@ -13,6 +13,7 @@ import {
   CmusUIStateContext,
   initialCmusUIState,
 } from './reducer';
+import { Search } from './search';
 import { Overlay, View } from './types';
 import { useLibrary } from './utils/library';
 import { ViewClientList } from './views/clients';
@@ -49,12 +50,14 @@ export const CmusUIProvider: UIProviderComponent = ({ currentSong, nextSong, pre
     }
   }, [stateUI.skipSong, nextSong, prevSong]);
 
-  useVimBindings(dispatchUI, stateUI.commandMode);
+  useVimBindings(dispatchUI, stateUI.commandMode || stateUI.searchMode);
 
   useLibrary(stateUI, dispatchUI);
 
   const showOverlay = !!stateUI.overlay || stateUI.view === View.ClientList;
 
+  const showLibrary = stateUI.view === View.Library || stateUI.view === View.ClientList;
+
   return (
     <CmusUIStateContext.Provider value={stateUI}>
       <CmusUIDispatchContext.Provider value={dispatchUI}>
@@ -68,9 +71,7 @@ export const CmusUIProvider: UIProviderComponent = ({ currentSong, nextSong, pre
             ))}
           </Styled.ViewTitle>
           <Styled.View>
-            {(stateUI.view === View.Library || stateUI.view === View.ClientList) && (
-              <ViewLibrary currentArtist={currentSong?.artist ?? null} />
-            )}
+            <ViewLibrary currentArtist={currentSong?.artist ?? null} hidden={!showLibrary} />
             {stateUI.view === View.Queue && <ViewQueue currentSong={currentSong} />}
           </Styled.View>
           {showOverlay && (
@@ -79,6 +80,7 @@ export const CmusUIProvider: UIProviderComponent = ({ currentSong, nextSong, pre
               {stateUI.overlay === Overlay.Help && <HelpDialog />}
             </Styled.Overlay>
           )}
+          {stateUI.searchMode && <Search />}
           <PlayerStatus song={currentSong} />
           <CommandView />
         </Styled.Wrapper>

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

@@ -10,6 +10,7 @@ export const Keys = {
   question: '?',
   pageDown: 'PageDown',
   pageUp: 'PageUp',
+  slash: '/',
   '1': '1',
   '2': '2',
   '3': '3',