瀏覽代碼

feat: shared scroll logic, highlight, spinners and lazy loading

    * feat: throttle key listener
    * feat: show spinner when loading songs
    * feat: lazy load UI provider
    * feat: highlight currently playing artist
    * refactor: moved current song info request into a shared hook
    * feat: highlight currently playing song
    * feat: auto scroll songs list
Fela Maslen 5 年之前
父節點
當前提交
6e8a475b06

+ 7 - 2
gmus/src/components/app.tsx

@@ -1,12 +1,14 @@
-import React, { Dispatch, useCallback } from 'react';
+import React, { Dispatch, Suspense, useCallback } from 'react';
 
 import { LocalAction, stateSet } from '../actions';
 import { DispatchContext, StateContext } from '../context/state';
 import { useMaster } from '../hooks/master';
 import { useKeepalive } from '../hooks/socket';
+import { useCurrentlyPlayingSongInfo } from '../hooks/status';
 import { GlobalState } from '../reducer';
 import { isMaster } from '../selectors';
 import { getSongUrl } from '../utils/url';
+import { LoadingWrapper } from './identify';
 import { Player } from './player';
 import { uiProviders } from './ui';
 import { UIProvider } from './ui/types';
@@ -23,6 +25,7 @@ const UI = uiProviders[uiProvider];
 export const App: React.FC<Props> = ({ socket, state, dispatch }) => {
   useKeepalive(socket);
   useMaster(state, dispatch);
+  const currentSong = useCurrentlyPlayingSongInfo(state.player.songId);
 
   const onTimeUpdate = useCallback(
     (currentTime: number): void => {
@@ -44,7 +47,9 @@ export const App: React.FC<Props> = ({ socket, state, dispatch }) => {
       )}
       <StateContext.Provider value={state}>
         <DispatchContext.Provider value={dispatch}>
-          <UI isMaster={isMaster(state)} />
+          <Suspense fallback={LoadingWrapper}>
+            <UI isMaster={isMaster(state)} currentSong={currentSong} />
+          </Suspense>
         </DispatchContext.Provider>
       </StateContext.Provider>
     </>

+ 10 - 3
gmus/src/components/identify.tsx

@@ -9,6 +9,12 @@ export type Props = {
   onIdentify: (name: string) => void;
 };
 
+export const LoadingWrapper: React.FC = () => (
+  <Styled.Container>
+    <CircleLoader size={100} color="white" />
+  </Styled.Container>
+);
+
 export const Identify: React.FC<Props> = ({ connecting, onIdentify }) => {
   const [name, setName] = useState<string>('');
   const onChange = useCallback(
@@ -36,6 +42,10 @@ export const Identify: React.FC<Props> = ({ connecting, onIdentify }) => {
   );
   const buttonHandlers = useCTA(onConnect);
 
+  if (connecting) {
+    return <LoadingWrapper />;
+  }
+
   return (
     <Styled.Container>
       <Styled.Title>go-music-player</Styled.Title>
@@ -46,9 +56,6 @@ export const Identify: React.FC<Props> = ({ connecting, onIdentify }) => {
           Connect
         </button>
       </Styled.InputGroup>
-      <Styled.Loader visible={connecting}>
-        <CircleLoader size={50} color="white" />
-      </Styled.Loader>
     </Styled.Container>
   );
 };

+ 3 - 1
gmus/src/components/ui/cmus/index.ts

@@ -1 +1,3 @@
-export * from './wrapper';
+import { CmusUIProvider } from './wrapper';
+
+export default CmusUIProvider;

+ 10 - 10
gmus/src/components/ui/cmus/utils/scroll.spec.ts

@@ -1,4 +1,4 @@
-import { getNextActiveArtistAndAlbum, getScrollIndex } from './scroll';
+import { getNextActiveArtistAndAlbum, getArtistAlbumScrollIndex } from './scroll';
 
 describe(getNextActiveArtistAndAlbum.name, () => {
   const artists: string[] = ['A', 'B', 'C'];
@@ -225,14 +225,14 @@ describe(getNextActiveArtistAndAlbum.name, () => {
   });
 });
 
-describe(getScrollIndex.name, () => {
+describe(getArtistAlbumScrollIndex.name, () => {
   describe('when on the Nth artist', () => {
     it('should return N', () => {
       expect.assertions(3);
 
-      expect(getScrollIndex(['A', 'B', 'C'], {}, 'A', null, [])).toBe(0);
-      expect(getScrollIndex(['A', 'B', 'C'], {}, 'B', null, [])).toBe(1);
-      expect(getScrollIndex(['A', 'B', 'C'], {}, 'C', null, [])).toBe(2);
+      expect(getArtistAlbumScrollIndex(['A', 'B', 'C'], {}, 'A', null, [])).toBe(0);
+      expect(getArtistAlbumScrollIndex(['A', 'B', 'C'], {}, 'B', null, [])).toBe(1);
+      expect(getArtistAlbumScrollIndex(['A', 'B', 'C'], {}, 'C', null, [])).toBe(2);
     });
   });
 
@@ -245,13 +245,13 @@ describe(getScrollIndex.name, () => {
         B: ['b1'],
       };
 
-      expect(getScrollIndex(['A', 'B'], artistAlbums, 'A', 'a1', ['A'])).toBe(1);
-      expect(getScrollIndex(['A', 'B'], artistAlbums, 'A', 'a2', ['A'])).toBe(2);
-      expect(getScrollIndex(['A', 'B'], artistAlbums, 'B', null, ['A'])).toBe(3);
+      expect(getArtistAlbumScrollIndex(['A', 'B'], artistAlbums, 'A', 'a1', ['A'])).toBe(1);
+      expect(getArtistAlbumScrollIndex(['A', 'B'], artistAlbums, 'A', 'a2', ['A'])).toBe(2);
+      expect(getArtistAlbumScrollIndex(['A', 'B'], artistAlbums, 'B', null, ['A'])).toBe(3);
 
-      expect(getScrollIndex(['A', 'B'], artistAlbums, 'B', 'b1', ['B'])).toBe(2);
+      expect(getArtistAlbumScrollIndex(['A', 'B'], artistAlbums, 'B', 'b1', ['B'])).toBe(2);
 
-      expect(getScrollIndex(['A', 'B'], artistAlbums, 'B', 'b1', ['A', 'B'])).toBe(4);
+      expect(getArtistAlbumScrollIndex(['A', 'B'], artistAlbums, 'B', 'b1', ['A', 'B'])).toBe(4);
     });
   });
 });

+ 43 - 1
gmus/src/components/ui/cmus/utils/scroll.ts

@@ -1,3 +1,5 @@
+import { RefObject, useEffect } from 'react';
+import { Song } from '../../../../types';
 import { scrollThroughItems } from '../../../../utils/delta';
 
 const getArtistAlbums = (
@@ -65,7 +67,7 @@ export function getNextActiveArtistAndAlbum(
   return { artist: activeArtist, album: nextAlbum };
 }
 
-export function getScrollIndex(
+export function getArtistAlbumScrollIndex(
   artists: string[],
   artistAlbums: Record<string, string[]>,
   activeArtist: string | null,
@@ -88,3 +90,43 @@ export function getScrollIndex(
 
   return result;
 }
+
+export function getSongScrollIndex(
+  filteredSongs: Pick<Song, 'id'>[],
+  activeSongId: number | null,
+): number {
+  if (activeSongId === null) {
+    return -1;
+  }
+  return filteredSongs.findIndex(({ id }) => id === activeSongId);
+}
+
+export const lineHeight = 16;
+export const scrollThresholdLines = 4;
+
+export function useAutoJumpyScroll(ref: RefObject<HTMLDivElement>, scrollIndex: number): void {
+  /* eslint-disable react-hooks/exhaustive-deps, no-param-reassign */
+  useEffect(() => {
+    if (!ref.current || scrollIndex === -1) {
+      return;
+    }
+    const heightInLines = Math.floor(ref.current.offsetHeight / lineHeight);
+    if (heightInLines < scrollThresholdLines + 1) {
+      return;
+    }
+
+    const scrollPosLines = Math.floor(ref.current.scrollTop / lineHeight);
+
+    const linesBefore = scrollIndex - scrollPosLines;
+    const linesAfter = scrollPosLines + heightInLines - scrollIndex;
+
+    if (linesBefore < 0 || linesAfter < 0) {
+      ref.current.scrollTop = Math.max(0, (scrollIndex - 1) * lineHeight);
+    } else if (linesAfter < scrollThresholdLines) {
+      ref.current.scrollTop += lineHeight;
+    } else if (linesBefore < scrollThresholdLines) {
+      ref.current.scrollTop -= lineHeight;
+    }
+  }, [scrollIndex]);
+  /* eslint-enable react-hooks/exhaustive-deps, no-param-reassign */
+}

+ 28 - 63
gmus/src/components/ui/cmus/views/artists.tsx

@@ -1,13 +1,5 @@
-import { useDebounce, useDebounceCallback } from '@react-hook/debounce';
-import React, {
-  CSSProperties,
-  useCallback,
-  useContext,
-  useEffect,
-  useMemo,
-  useRef,
-  useState,
-} from 'react';
+import { useDebounce } from '@react-hook/debounce';
+import React, { CSSProperties, useContext, useEffect, useMemo, useRef } from 'react';
 import AutoSizer from 'react-virtualized-auto-sizer';
 import { FixedSizeList as List } from 'react-window';
 
@@ -17,12 +9,13 @@ import { artistAlbumsLoaded, artistSongsLoaded } from '../actions';
 import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
 import { NoWrapFill } from '../styled/layout';
 import { AsciiSpinner } from '../styled/spinner';
-import { getScrollIndex } from '../utils/scroll';
+import { getArtistAlbumScrollIndex, lineHeight, useAutoJumpyScroll } from '../utils/scroll';
 
 import * as Styled from './artists.styles';
 
-type Props = {
+export type Props = {
   active: boolean;
+  currentArtist: string | null;
 };
 
 type ArtistData = {
@@ -31,6 +24,7 @@ type ArtistData = {
   loading: boolean;
   active: boolean;
   parentActive: boolean;
+  highlight: boolean;
 };
 
 type AlbumData = {
@@ -48,8 +42,13 @@ const itemKey = (index: number, data: RowData[]): string => data[index].id;
 
 const Artist = namedMemo<{ row: ArtistData; style: CSSProperties }>(
   'Artist',
-  ({ row: { artist, loading, active, parentActive }, style }) => (
-    <Styled.ArtistTitle active={active} parentActive={parentActive} style={style}>
+  ({ row: { artist, loading, active, parentActive, highlight }, style }) => (
+    <Styled.ArtistTitle
+      active={active}
+      parentActive={parentActive}
+      style={style}
+      highlight={highlight}
+    >
       {loading ? <AsciiSpinner /> : <>&nbsp;&nbsp;</>}
       <NoWrapFill>{artist || 'Unknown Artist'}</NoWrapFill>
     </Styled.ArtistTitle>
@@ -76,10 +75,7 @@ const Row = namedMemo<{ index: number; data: RowData[]; style: CSSProperties }>(
   },
 );
 
-const lineHeight = 16;
-const scrollThresholdLines = 4;
-
-export const Artists: React.FC<Props> = ({ active: parentActive }) => {
+export const Artists: React.FC<Props> = ({ active: parentActive, currentArtist }) => {
   const dispatchUI = useContext(CmusUIDispatchContext);
   const state = useContext(CmusUIStateContext);
   const {
@@ -88,7 +84,7 @@ export const Artists: React.FC<Props> = ({ active: parentActive }) => {
     library: { activeArtist, activeAlbum, expandedArtists },
   } = state;
 
-  const [debouncedActiveArtist, setDebouncedActiveArtist] = useDebounce(activeArtist, 100);
+  const [debouncedActiveArtist, setDebouncedActiveArtist] = useDebounce(activeArtist, 200);
   useEffect(() => {
     setDebouncedActiveArtist(activeArtist);
   }, [activeArtist, setDebouncedActiveArtist]);
@@ -119,6 +115,7 @@ export const Artists: React.FC<Props> = ({ active: parentActive }) => {
           loading: !(artist in artistAlbums) && expandedArtists.includes(artist),
           active: activeArtist === artist && activeAlbum === null,
           parentActive,
+          highlight: currentArtist === artist,
         };
 
         if (!expanded) {
@@ -136,32 +133,19 @@ export const Artists: React.FC<Props> = ({ active: parentActive }) => {
           })),
         ];
       }, []),
-    [parentActive, artists, artistAlbums, activeArtist, activeAlbum, expandedArtists],
+    [
+      parentActive,
+      artists,
+      artistAlbums,
+      activeArtist,
+      activeAlbum,
+      expandedArtists,
+      currentArtist,
+    ],
   );
 
-  const ref = useRef<HTMLDivElement>(null);
-  const [windowDimensions, setWindowDimensions] = useState<{ width: number; height: number }>({
-    width: 0,
-    height: 0,
-  });
-  const onResize = useCallback(() => {
-    setWindowDimensions({
-      width: ref.current?.offsetWidth ?? 0,
-      height: ref.current?.offsetHeight ?? 0,
-    });
-  }, []);
-  const resizeHandler = useDebounceCallback(onResize, 100);
-
-  useEffect(() => {
-    onResize();
-    window.addEventListener('resize', resizeHandler);
-    return (): void => {
-      window.removeEventListener('resize', resizeHandler);
-    };
-  }, [onResize, resizeHandler]);
-
   const windowRef = useRef<HTMLDivElement>(null);
-  const scrollIndex = getScrollIndex(
+  const scrollIndex = getArtistAlbumScrollIndex(
     state.artists,
     state.artistAlbums,
     state.library.activeArtist,
@@ -169,29 +153,10 @@ export const Artists: React.FC<Props> = ({ active: parentActive }) => {
     state.library.expandedArtists,
   );
 
-  useEffect(() => {
-    if (!windowRef.current) {
-      return;
-    }
-    const heightInLines = Math.floor(windowDimensions.height / lineHeight);
-    if (heightInLines < scrollThresholdLines + 1) {
-      return;
-    }
-
-    const scrollPosLines = Math.floor(windowRef.current.scrollTop / lineHeight);
-
-    const linesBefore = scrollIndex - scrollPosLines;
-    const linesAfter = scrollPosLines + heightInLines - scrollIndex;
-
-    if (linesAfter < scrollThresholdLines) {
-      windowRef.current.scrollTop += lineHeight;
-    } else if (linesBefore < scrollThresholdLines) {
-      windowRef.current.scrollTop -= lineHeight;
-    }
-  }, [windowDimensions.height, scrollIndex]);
+  useAutoJumpyScroll(windowRef, scrollIndex);
 
   return (
-    <Styled.Container ref={ref}>
+    <Styled.Container>
       <AutoSizer>
         {({ height, width }): React.ReactElement => (
           <List

+ 8 - 3
gmus/src/components/ui/cmus/views/library.tsx

@@ -6,10 +6,12 @@ import { artistsSet } from '../actions';
 import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
 import { LibraryModeWindow } from '../types';
 
-import { Artists } from './artists';
+import { Artists, Props as PropsArtists } from './artists';
 import { Songs } from './songs';
 
-export const ViewLibrary: React.FC = () => {
+export type Props = Pick<PropsArtists, 'currentArtist'>;
+
+export const ViewLibrary: React.FC<Props> = ({ currentArtist }) => {
   const dispatchUI = useContext(CmusUIDispatchContext);
   const { library } = useContext(CmusUIStateContext);
 
@@ -20,7 +22,10 @@ export const ViewLibrary: React.FC = () => {
 
   return (
     <>
-      <Artists active={library.modeWindow === LibraryModeWindow.ArtistList} />
+      <Artists
+        active={library.modeWindow === LibraryModeWindow.ArtistList}
+        currentArtist={currentArtist}
+      />
       <Songs active={library.modeWindow === LibraryModeWindow.SongList} />
     </>
   );

+ 74 - 9
gmus/src/components/ui/cmus/views/songs.tsx

@@ -1,7 +1,13 @@
-import React, { useContext, useMemo } from 'react';
+import React, { CSSProperties, useContext, useMemo, useRef } from 'react';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import { FixedSizeList as List } from 'react-window';
+import { StateContext } from '../../../../context/state';
 
 import { Song } from '../../../../types';
+import { namedMemo } from '../../../../utils/component';
 import { CmusUIStateContext } from '../reducer';
+import { AsciiSpinner } from '../styled/spinner';
+import { getSongScrollIndex, lineHeight, useAutoJumpyScroll } from '../utils/scroll';
 
 import * as Styled from './songs.styles';
 
@@ -11,29 +17,88 @@ type Props = {
 
 const emptyArray: Song[] = [];
 
-export const Songs: React.FC<Props> = ({ active }) => {
+type SongData = {
+  song: Song;
+  active: boolean;
+  parentActive: boolean;
+  highlight: boolean;
+};
+
+const itemKey = (index: number, data: SongData[]): number => data[index].song.id;
+
+const Row = namedMemo<{ index: number; data: SongData[]; style: CSSProperties }>(
+  'Song',
+  ({ index, data, style }) => {
+    const { song, active, parentActive, highlight } = data[index];
+    return (
+      <Styled.Song active={active} parentActive={parentActive} style={style} highlight={highlight}>
+        {song.track} - {song.title || 'Untitled Track'}
+      </Styled.Song>
+    );
+  },
+);
+
+export const Songs: React.FC<Props> = ({ active: parentActive }) => {
   const {
     artistSongs,
     library: { activeArtist, activeAlbum, activeSongId },
   } = useContext(CmusUIStateContext);
 
+  const {
+    player: { songId: playingSongId },
+  } = useContext(StateContext);
+
   const activeArtistSongs = activeArtist ? artistSongs[activeArtist] ?? emptyArray : emptyArray;
 
-  const songs = useMemo<Song[]>(
+  const filteredSongs = useMemo<Song[]>(
     () =>
       activeAlbum
         ? activeArtistSongs.filter(({ album }) => album === activeAlbum)
         : activeArtistSongs,
-    [activeArtistSongs, activeAlbum],
+    [activeAlbum, activeArtistSongs],
   );
 
+  const itemData = useMemo<SongData[]>(
+    () =>
+      filteredSongs.map<SongData>((song) => ({
+        song,
+        active: song.id === activeSongId,
+        parentActive,
+        highlight: song.id === playingSongId,
+      })),
+    [parentActive, activeSongId, playingSongId, filteredSongs],
+  );
+
+  const windowRef = useRef<HTMLDivElement>(null);
+  const scrollIndex = getSongScrollIndex(filteredSongs, activeSongId);
+
+  useAutoJumpyScroll(windowRef, scrollIndex);
+
+  if (activeArtist && !(activeArtist in artistSongs)) {
+    return (
+      <Styled.Container>
+        <AsciiSpinner />
+      </Styled.Container>
+    );
+  }
+
   return (
     <Styled.Container>
-      {songs.map((song) => (
-        <Styled.Song key={song.id} active={song.id === activeSongId} parentActive={active}>
-          {song.track} - {song.title || 'Untitled Track'}
-        </Styled.Song>
-      ))}
+      <AutoSizer>
+        {({ height, width }): React.ReactElement => (
+          <List
+            outerRef={windowRef}
+            height={height}
+            width={width}
+            itemCount={itemData.length}
+            itemSize={lineHeight}
+            itemKey={itemKey}
+            itemData={itemData}
+          >
+            {Row}
+          </List>
+        )}
+      </AutoSizer>
     </Styled.Container>
   );
 };

+ 16 - 24
gmus/src/components/ui/cmus/views/status.spec.tsx

@@ -1,40 +1,32 @@
 import { render, RenderResult, waitFor } from '@testing-library/react';
-import nock from 'nock';
 import React from 'react';
 
 import { StateContext } from '../../../../context/state';
 import { GlobalState, initialState, nullPlayer } from '../../../../reducer';
+import { Song } from '../../../../types';
 
 import { PlayerStatus } from './status';
 
 describe(PlayerStatus.name, () => {
-  const nockSongInfo = (id: number): nock.Scope =>
-    nock('http://my-api.url:1234').get(`/song-info?id=${id}`).reply(
-      200,
-      {
-        track: 12,
-        title: 'My song',
-        artist: 'My artist',
-        album: 'My album',
-        time: 374,
-      },
-      { 'Access-Control-Allow-Origin': '*' },
-    );
-
   const testSongId = 23;
 
-  const setup = (globalState: Partial<GlobalState> = {}): RenderResult =>
+  const testSong: Song = {
+    id: testSongId,
+    track: 12,
+    title: 'My song',
+    artist: 'My artist',
+    album: 'My album',
+    time: 374,
+  };
+
+  const setup = (globalState: Partial<GlobalState> = {}, song: Song | null): RenderResult =>
     render(
       <StateContext.Provider value={{ ...initialState, ...globalState }}>
-        <PlayerStatus />
+        <PlayerStatus song={song} />
       </StateContext.Provider>,
     );
 
   describe('when a song is active', () => {
-    beforeEach(() => {
-      nockSongInfo(testSongId);
-    });
-
     const stateWithSongActive: Partial<GlobalState> = {
       player: {
         ...nullPlayer,
@@ -48,7 +40,7 @@ describe(PlayerStatus.name, () => {
       ${'artist, album, title and track number'} | ${'My artist - My album - 12. My song'}
       ${'current and total play time'}           | ${'02:08 / 06:14'}
     `('should render the $property', async ({ expectedValue }) => {
-      const { getByText } = setup(stateWithSongActive);
+      const { getByText } = setup(stateWithSongActive, testSong);
 
       await waitFor(() => {
         expect(getByText(expectedValue)).toBeInTheDocument();
@@ -66,7 +58,7 @@ describe(PlayerStatus.name, () => {
 
       it('should display a playing indicator', () => {
         expect.assertions(1);
-        const { getByText } = setup(statePlaying);
+        const { getByText } = setup(statePlaying, testSong);
         expect(getByText('>')).toBeInTheDocument();
       });
     });
@@ -82,7 +74,7 @@ describe(PlayerStatus.name, () => {
 
       it('should display a paused indicator', () => {
         expect.assertions(1);
-        const { getByText } = setup(stateNotPlaying);
+        const { getByText } = setup(stateNotPlaying, testSong);
         expect(getByText('|')).toBeInTheDocument();
       });
     });
@@ -95,7 +87,7 @@ describe(PlayerStatus.name, () => {
 
     it('should display an inactive indicator', () => {
       expect.assertions(1);
-      const { getByText } = setup(stateWithSongInactive);
+      const { getByText } = setup(stateWithSongInactive, null);
       expect(getByText('.')).toBeInTheDocument();
     });
   });

+ 11 - 36
gmus/src/components/ui/cmus/views/status.tsx

@@ -1,29 +1,20 @@
-import { AxiosInstance, AxiosResponse } from 'axios';
-import React, { useCallback, useContext, useMemo, useState } from 'react';
-
+import React, { useContext } from 'react';
 import { StateContext } from '../../../../context/state';
-import { useCancellableRequest } from '../../../../hooks/request';
-import { getSongId } from '../../../../selectors';
+
 import { MusicPlayer, Song } from '../../../../types';
 import { formatTime } from '../../../../utils/time';
-import { getApiUrl } from '../../../../utils/url';
 
 import * as Styled from './status.styles';
 
-type SongInfoQuery = { id: number };
-
-const sendSongInfoRequest = (
-  axios: AxiosInstance,
-  query: SongInfoQuery,
-): Promise<AxiosResponse<Song>> => axios.get(`${getApiUrl()}/song-info?id=${query.id}`);
+export type Props = {
+  song: Song | null;
+};
 
-function getTrackMetadata(songInfo: Song | null): string {
-  if (!songInfo) {
+function getTrackMetadata(song: Song | null): string {
+  if (!song) {
     return '';
   }
-  return `${songInfo.artist} - ${songInfo.album} - ${songInfo.track ? `${songInfo.track}. ` : ''}${
-    songInfo.title
-  }`;
+  return `${song.artist} - ${song.album} - ${song.track ? `${song.track}. ` : ''}${song.title}`;
 }
 
 function getPlayPauseIcon(player: MusicPlayer): string {
@@ -36,31 +27,15 @@ function getPlayPauseIcon(player: MusicPlayer): string {
   return '|';
 }
 
-export const PlayerStatus: React.FC = () => {
+export const PlayerStatus: React.FC<Props> = ({ song }) => {
   const state = useContext(StateContext);
-  const songId = getSongId(state);
-
-  const query = useMemo<SongInfoQuery>(() => (songId ? { id: songId } : { id: 0 }), [songId]);
-
-  const [songInfo, setSongInfo] = useState<Song | null>(null);
-  const handleResponse = useCallback((res: Song) => {
-    setSongInfo(res);
-  }, []);
-
-  useCancellableRequest<SongInfoQuery, Song>({
-    query,
-    pause: !songId,
-    sendRequest: sendSongInfoRequest,
-    handleResponse,
-  });
-
   return (
     <Styled.StatusContainer>
-      <Styled.TrackMetadata>{getTrackMetadata(songInfo)}</Styled.TrackMetadata>
+      <Styled.TrackMetadata>{getTrackMetadata(song)}</Styled.TrackMetadata>
       <Styled.PlayStatus>
         <span>{getPlayPauseIcon(state.player)}</span>
         <span>
-          {formatTime(state.player.currentTime)} / {formatTime(songInfo?.time ?? null)}
+          {formatTime(state.player.currentTime)} / {formatTime(song?.time ?? null)}
         </span>
       </Styled.PlayStatus>
     </Styled.StatusContainer>

+ 7 - 3
gmus/src/components/ui/cmus/wrapper.tsx

@@ -21,7 +21,7 @@ import { PlayerStatus } from './views/status';
 
 import * as Styled from './wrapper.styles';
 
-export const CmusUIProvider: UIProviderComponent = () => {
+export const CmusUIProvider: UIProviderComponent = ({ currentSong }) => {
   const dispatch = useContext(DispatchContext);
   const [stateUI, dispatchUI] = useReducer<Reducer<CmusUIState, CmusUIAction>>(
     cmusUIReducer,
@@ -40,13 +40,17 @@ export const CmusUIProvider: UIProviderComponent = () => {
     <CmusUIStateContext.Provider value={stateUI}>
       <CmusUIDispatchContext.Provider value={dispatchUI}>
         <Styled.Wrapper>
-          <Styled.View>{stateUI.view === View.Library && <ViewLibrary />}</Styled.View>
+          <Styled.View>
+            {stateUI.view === View.Library && (
+              <ViewLibrary currentArtist={currentSong?.artist ?? null} />
+            )}
+          </Styled.View>
           {!!stateUI.overlay && (
             <>
               <Styled.Overlay>{stateUI.overlay === Overlay.Help && <HelpDialog />}</Styled.Overlay>
             </>
           )}
-          <PlayerStatus />
+          <PlayerStatus song={currentSong} />
           <CommandView />
         </Styled.Wrapper>
       </CmusUIDispatchContext.Provider>

+ 2 - 2
gmus/src/components/ui/index.ts

@@ -1,6 +1,6 @@
-import { CmusUIProvider } from './cmus';
+import { lazy } from 'react';
 import { UIProvider, UIProviders } from './types';
 
 export const uiProviders: UIProviders = {
-  [UIProvider.Cmus]: CmusUIProvider,
+  [UIProvider.Cmus]: lazy(() => import('./cmus')),
 };

+ 3 - 0
gmus/src/components/ui/types.ts

@@ -1,11 +1,14 @@
 import React from 'react';
 
+import { Song } from '../../types';
+
 export enum UIProvider {
   Cmus = 'Cmus',
 }
 
 export type UIProps = {
   isMaster: boolean;
+  currentSong: Song | null;
 };
 
 export type UIProviderComponent = React.FC<UIProps>;

+ 46 - 0
gmus/src/hooks/status.spec.tsx

@@ -0,0 +1,46 @@
+import { render, waitFor } from '@testing-library/react';
+import nock from 'nock';
+import React from 'react';
+import { Song } from '../types';
+
+import { useCurrentlyPlayingSongInfo } from './status';
+
+describe(useCurrentlyPlayingSongInfo.name, () => {
+  const TestComponent: React.FC<{ songId: number | null }> = ({ songId }) => {
+    const songInfo = useCurrentlyPlayingSongInfo(songId);
+    return <div data-testid="info">{JSON.stringify(songInfo)}</div>;
+  };
+
+  describe('when there is no song ID', () => {
+    it('should return null', () => {
+      expect.assertions(1);
+      const { getByTestId } = render(<TestComponent songId={null} />);
+      expect(JSON.parse(getByTestId('info').innerHTML)).toBeNull();
+    });
+  });
+
+  describe('when there is a song ID in state', () => {
+    const testSong: Song = {
+      id: 1765,
+      track: 12,
+      title: 'My song',
+      artist: 'My artist',
+      album: 'My album',
+      time: 218,
+    };
+
+    beforeEach(() => {
+      nock('http://my-api.url:1234')
+        .get('/song-info?id=1765')
+        .reply(200, testSong, { 'Access-Control-Allow-Origin': '*' });
+    });
+
+    it('should return the song info from the API', async () => {
+      expect.assertions(2);
+      const { getByTestId } = render(<TestComponent songId={1765} />);
+      await waitFor(() => {
+        expect(JSON.parse(getByTestId('info').innerHTML)).toStrictEqual(testSong);
+      });
+    });
+  });
+});

+ 30 - 0
gmus/src/hooks/status.ts

@@ -0,0 +1,30 @@
+import { AxiosInstance, AxiosResponse } from 'axios';
+import { useCallback, useMemo, useState } from 'react';
+
+import { Song } from '../types';
+import { getApiUrl } from '../utils/url';
+import { useCancellableRequest } from './request';
+
+type SongInfoQuery = { id: number };
+
+const sendSongInfoRequest = (
+  axios: AxiosInstance,
+  query: SongInfoQuery,
+): Promise<AxiosResponse<Song>> => axios.get(`${getApiUrl()}/song-info?id=${query.id}`);
+
+export function useCurrentlyPlayingSongInfo(songId: number | null): Song | null {
+  const [songInfo, setSongInfo] = useState<Song | null>(null);
+  const handleResponse = useCallback((res: Song) => {
+    setSongInfo(res);
+  }, []);
+
+  const query = useMemo<SongInfoQuery>(() => (songId ? { id: songId } : { id: 0 }), [songId]);
+
+  useCancellableRequest<SongInfoQuery, Song>({
+    query,
+    pause: !songId,
+    sendRequest: sendSongInfoRequest,
+    handleResponse,
+  });
+  return songInfo;
+}

+ 16 - 0
gmus/src/hooks/vim.spec.tsx

@@ -17,6 +17,8 @@ describe(useVimBindings.name, () => {
   describe('when a key is pressed', () => {
     it('should dispatch a KeyPress action', () => {
       expect.assertions(2);
+      jest.useFakeTimers();
+
       render(<TestComponent dispatch={dispatch} />);
 
       expect(dispatch).not.toHaveBeenCalled();
@@ -28,12 +30,15 @@ describe(useVimBindings.name, () => {
       });
 
       expect(dispatch).toHaveBeenCalledWith({ type: ActionTypeKeyPressed, key: 'Tab' });
+
+      jest.useRealTimers();
     });
   });
 
   describe('when the key is unhandled', () => {
     it('should not dispatch anything', () => {
       expect.assertions(2);
+      jest.useFakeTimers();
       render(<TestComponent dispatch={dispatch} />);
 
       expect(dispatch).not.toHaveBeenCalled();
@@ -43,14 +48,20 @@ describe(useVimBindings.name, () => {
           key: '@',
         });
       });
+      act(() => {
+        jest.runAllTimers();
+      });
 
       expect(dispatch).not.toHaveBeenCalled();
+
+      jest.useRealTimers();
     });
   });
 
   describe('when skipping', () => {
     it('should not listen to any keys', () => {
       expect.assertions(1);
+      jest.useFakeTimers();
       render(<TestComponent dispatch={dispatch} skip={true} />);
 
       act(() => {
@@ -58,8 +69,13 @@ describe(useVimBindings.name, () => {
           key: 'Tab',
         });
       });
+      act(() => {
+        jest.runAllTimers();
+      });
 
       expect(dispatch).not.toHaveBeenCalled();
+
+      jest.useRealTimers();
     });
   });
 });

+ 19 - 13
gmus/src/hooks/vim.ts

@@ -1,4 +1,5 @@
-import { Dispatch, useEffect } from 'react';
+import { useThrottleCallback } from '@react-hook/throttle';
+import { Dispatch, useCallback, useEffect } from 'react';
 
 export const Keys = {
   tab: 'Tab',
@@ -23,14 +24,8 @@ export type ActionKeyPressed = {
 };
 
 export function useVimBindings(dispatch: Dispatch<ActionKeyPressed>, skip = false): void {
-  useEffect(() => {
-    if (skip) {
-      return (): void => {
-        // pass
-      };
-    }
-
-    const listener = (event: KeyboardEvent): void => {
+  const listener = useCallback(
+    (event: KeyboardEvent): void => {
       if (!availableKeys.includes(event.key)) {
         return;
       }
@@ -39,11 +34,22 @@ export function useVimBindings(dispatch: Dispatch<ActionKeyPressed>, skip = fals
 
       const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: event.key };
       dispatch(action);
-    };
+    },
+    [dispatch],
+  );
+
+  const listenerThrottled = useThrottleCallback(listener, 20, true);
+
+  useEffect(() => {
+    if (skip) {
+      return (): void => {
+        // pass
+      };
+    }
 
-    window.addEventListener('keydown', listener);
+    window.addEventListener('keydown', listenerThrottled);
     return (): void => {
-      window.removeEventListener('keydown', listener);
+      window.removeEventListener('keydown', listenerThrottled);
     };
-  }, [dispatch, skip]);
+  }, [skip, listenerThrottled]);
 }