Browse Source

feat: component for current player status

Fela Maslen 5 years ago
parent
commit
67d53c1a19

+ 5 - 1
gmus/src/components/ui/cmus/index.tsx

@@ -8,6 +8,7 @@ import {
 } from '../../../hooks/vim';
 
 import { UIProviderComponent } from '../types';
+import { PlayerStatus } from './status';
 
 import * as Styled from './styles';
 import { ViewLibrary } from './views/library';
@@ -32,7 +33,10 @@ export const CmusUIProvider: UIProviderComponent = () => {
 
   return (
     <KeyPressedContext.Provider value={lastKeyPress}>
-      <Styled.Wrapper>{view === View.Library && <ViewLibrary />}</Styled.Wrapper>
+      <Styled.Wrapper>
+        <Styled.View>{view === View.Library && <ViewLibrary />}</Styled.View>
+        <PlayerStatus />
+      </Styled.Wrapper>
     </KeyPressedContext.Provider>
   );
 };

+ 102 - 0
gmus/src/components/ui/cmus/status/index.spec.tsx

@@ -0,0 +1,102 @@
+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 { PlayerStatus } from '.';
+
+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 =>
+    render(
+      <StateContext.Provider value={{ ...initialState, ...globalState }}>
+        <PlayerStatus />
+      </StateContext.Provider>,
+    );
+
+  describe('when a song is active', () => {
+    beforeEach(() => {
+      nockSongInfo(testSongId);
+    });
+
+    const stateWithSongActive: Partial<GlobalState> = {
+      player: {
+        ...nullPlayer,
+        currentTime: 128,
+        songId: testSongId,
+      },
+    };
+
+    it.each`
+      property                                   | expectedValue
+      ${'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);
+
+      await waitFor(() => {
+        expect(getByText(expectedValue)).toBeInTheDocument();
+      });
+    });
+
+    describe('when playing', () => {
+      const statePlaying: Partial<GlobalState> = {
+        player: {
+          ...initialState.player,
+          songId: testSongId,
+          playing: true,
+        },
+      };
+
+      it('should display a playing indicator', () => {
+        expect.assertions(1);
+        const { getByText } = setup(statePlaying);
+        expect(getByText('>')).toBeInTheDocument();
+      });
+    });
+
+    describe('when not playing', () => {
+      const stateNotPlaying: Partial<GlobalState> = {
+        player: {
+          ...nullPlayer,
+          songId: testSongId,
+          playing: false,
+        },
+      };
+
+      it('should display a paused indicator', () => {
+        expect.assertions(1);
+        const { getByText } = setup(stateNotPlaying);
+        expect(getByText('|')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('when no song is active', () => {
+    const stateWithSongInactive: Partial<GlobalState> = {
+      player: { ...nullPlayer, songId: null },
+    };
+
+    it('should display an inactive indicator', () => {
+      expect.assertions(1);
+      const { getByText } = setup(stateWithSongInactive);
+      expect(getByText('.')).toBeInTheDocument();
+    });
+  });
+});

+ 68 - 0
gmus/src/components/ui/cmus/status/index.tsx

@@ -0,0 +1,68 @@
+import { AxiosInstance, AxiosResponse } from 'axios';
+import React, { useCallback, useContext, useMemo, useState } 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 './styles';
+
+type SongInfoQuery = { id: number };
+
+const sendSongInfoRequest = (
+  axios: AxiosInstance,
+  query: SongInfoQuery,
+): Promise<AxiosResponse<Song>> => axios.get(`${getApiUrl()}/song-info?id=${query.id}`);
+
+function getTrackMetadata(songInfo: Song | null): string {
+  if (!songInfo) {
+    return '';
+  }
+  return `${songInfo.artist} - ${songInfo.album} - ${songInfo.track ? `${songInfo.track}. ` : ''}${
+    songInfo.title
+  }`;
+}
+
+function getPlayPauseIcon(player: MusicPlayer): string {
+  if (!player.songId) {
+    return '.';
+  }
+  if (player.playing) {
+    return '>';
+  }
+  return '|';
+}
+
+export const PlayerStatus: React.FC = () => {
+  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.PlayStatus>
+        <span>{getPlayPauseIcon(state.player)}</span>
+        <span>
+          {formatTime(state.player.currentTime)} / {formatTime(songInfo?.time ?? null)}
+        </span>
+      </Styled.PlayStatus>
+    </Styled.StatusContainer>
+  );
+};

+ 25 - 0
gmus/src/components/ui/cmus/status/styles.ts

@@ -0,0 +1,25 @@
+import { rem } from 'polished';
+import styled from 'styled-components';
+
+import { FlexColumn, FlexRow } from '../shared/layout';
+import { colors } from '../variables';
+
+export const StatusContainer = styled(FlexColumn)`
+  flex: 0 0 ${rem(32)};
+  width: 100%;
+`;
+
+export const TrackMetadata = styled(FlexRow)`
+  background: ${colors.selected.background};
+  color: ${colors.foreground};
+  flex: 1;
+  white-space: nowrap;
+  width: 100%;
+`;
+
+export const PlayStatus = styled(FlexRow)`
+  background: ${colors.selected.inactive};
+  color: ${colors.background};
+  flex: 1;
+  width: 100%;
+`;

+ 6 - 2
gmus/src/components/ui/cmus/styles.ts

@@ -1,10 +1,10 @@
 import { rem } from 'polished';
 import styled from 'styled-components';
 
-import { FlexRow } from './shared/layout';
+import { FlexColumn, FlexRow } from './shared/layout';
 import { colors } from './variables';
 
-export const Wrapper = styled(FlexRow)`
+export const Wrapper = styled(FlexColumn)`
   background: ${colors.background};
   bottom: 0;
   color: ${colors.foreground};
@@ -17,3 +17,7 @@ export const Wrapper = styled(FlexRow)`
   top: 0;
   user-select: none;
 `;
+
+export const View = styled(FlexRow)`
+  flex: 1;
+`;

+ 2 - 0
gmus/src/selectors.ts

@@ -13,3 +13,5 @@ export const willBeMaster = (
   state: Partial<GlobalState> & Pick<GlobalState, 'myClientName'>,
   action: ActionStateSetLocal | ActionStateSetRemote,
 ): boolean => state.myClientName === action.payload?.master;
+
+export const getSongId = (state: Pick<GlobalState, 'player'>): number | null => state.player.songId;

+ 10 - 0
gmus/src/setupTests.ts

@@ -3,7 +3,17 @@
 // expect(element).toHaveTextContent(/react/i)
 // learn more: https://github.com/testing-library/jest-dom
 import '@testing-library/jest-dom';
+import nock from 'nock';
 
 beforeEach(() => {
   jest.restoreAllMocks();
 });
+
+beforeAll(() => {
+  nock.disableNetConnect();
+  nock.enableNetConnect('http://my-api.url:1234');
+});
+
+afterAll(() => {
+  nock.enableNetConnect();
+});

+ 2 - 0
gmus/src/types/index.ts

@@ -0,0 +1,2 @@
+export * from './songs';
+export * from './state';