فهرست منبع

feat(wip): split up web client code and added global reducer with side effects

Fela Maslen 5 سال پیش
والد
کامیت
68e517f7c5

+ 120 - 0
gmus/.eslintrc.js

@@ -0,0 +1,120 @@
+module.exports = {
+  plugins: ['jsx-a11y', 'prettier', 'react', 'react-hooks', 'import', 'jest'],
+  parser: '@typescript-eslint/parser',
+  parserOptions: {
+    ecmaVersion: 8,
+    tokens: true,
+    sourceType: 'module',
+  },
+  env: {
+    browser: true,
+    commonjs: true,
+    es6: true,
+    node: true,
+    jest: true,
+  },
+  extends: [
+    'airbnb-base',
+    'prettier',
+    'plugin:@typescript-eslint/eslint-recommended',
+    'plugin:@typescript-eslint/recommended',
+    'plugin:jest/all',
+    'plugin:jsx-a11y/recommended',
+    'plugin:react/recommended',
+  ],
+  settings: {
+    'import/extensions': ['.ts', '.tsx', '.js', '.jsx'],
+    'import/parsers': {
+      '@typescript-eslint/parser': ['.ts', '.tsx'],
+    },
+    'import/resolver': {
+      node: {
+        extensions: ['.js', '.jsx', '.ts', '.tsx'],
+      },
+    },
+    react: {
+      version: 'detect',
+    },
+  },
+  rules: {
+    '@typescript-eslint/camelcase': 'off',
+    '@typescript-eslint/explicit-function-return-type': 'off',
+    '@typescript-eslint/no-explicit-any': [
+      'error',
+      {
+        ignoreRestArgs: true,
+      },
+    ],
+    '@typescript-eslint/no-shadow': 'error',
+    '@typescript-eslint/no-use-before-define': 'error',
+    '@typescript-eslint/no-var-requires': 'off',
+    camelcase: 'off',
+    'import/extensions': [
+      'error',
+      'ignorePackages',
+      {
+        '': 'never',
+        js: 'never',
+        jsx: 'never',
+        ts: 'never',
+        tsx: 'never',
+      },
+    ],
+    'import/order': [
+      'error',
+      {
+        groups: ['builtin', 'external', 'parent', 'sibling', 'index'],
+        alphabetize: {
+          order: 'asc',
+          caseInsensitive: true,
+        },
+      },
+    ],
+    'import/no-extraneous-dependencies': [
+      'error',
+      {
+        devDependencies: ['**/*spec.ts', '**/*.spec.tsx', '**/*.spec.js'],
+      },
+    ],
+    'import/prefer-default-export': 'off',
+    'jest/lowercase-name': 'off',
+    'jest/no-hooks': 'off',
+    'jest/prefer-expect-assertions': 'warn',
+    'jest/prefer-strict-equal': 'warn',
+    'jest/require-top-level-describe': 'off',
+    'jest/valid-title': [
+      'error',
+      {
+        ignoreTypeOfDescribeName: true,
+      },
+    ],
+    'max-len': ['error', 120],
+    'no-bitwise': 'off',
+    'no-underscore-dangle': 'off',
+    'no-shadow': 'off',
+    'no-use-before-define': 'off',
+    'no-warning-comments': 'warn',
+    'prettier/prettier': ['error'],
+    'react/jsx-filename-extension': [
+      2,
+      {
+        extensions: ['.js', '.jsx', '.tsx'],
+      },
+    ],
+    'react/prop-types': 'off',
+    'react-hooks/rules-of-hooks': 'error',
+    'react-hooks/exhaustive-deps': 'warn',
+  },
+  overrides: [
+    {
+      files: ['*.ts', '*.tsx'],
+      rules: {
+        'no-undef-init': 'off',
+        'prefer-destructuring': 'off',
+        '@typescript-eslint/explicit-function-return-type': ['error'],
+        '@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }],
+        '@typescript-eslint/no-var-requires': ['error'],
+      },
+    },
+  ],
+};

+ 7 - 0
gmus/package.json

@@ -5,6 +5,7 @@
   "scripts": {
     "start": "react-scripts start",
     "build": "react-scripts build",
+    "lint": "eslint src",
     "test": "react-scripts test",
     "eject": "react-scripts eject"
   },
@@ -34,9 +35,15 @@
     "@types/node": "^12.0.0",
     "@types/react": "^16.9.53",
     "@types/react-dom": "^16.9.8",
+    "eslint-config-airbnb-base": "^14.2.1",
+    "eslint-config-prettier": "^7.0.0",
+    "eslint-plugin-prettier": "^3.3.0",
+    "prettier": "^2.2.1",
     "react": "^17.0.1",
     "react-dom": "^17.0.1",
     "react-scripts": "4.0.1",
+    "react-spinners": "^0.9.0",
+    "react-storage-hooks": "^4.0.1",
     "typescript": "^4.0.3",
     "web-vitals": "^0.2.4"
   },

+ 39 - 0
gmus/src/components/client-list/index.tsx

@@ -0,0 +1,39 @@
+import React, { useCallback } from 'react';
+import { useCTA } from '../../hooks/cta';
+
+export type Props = {
+  clients: string[];
+};
+
+type PropsListItem = {
+  name: string;
+  onSelect: (name: string) => void;
+};
+
+const ClientListItem: React.FC<PropsListItem> = ({ name, onSelect }) => {
+  const onActivate = useCallback(() => {
+    onSelect(name);
+  }, [onSelect, name]);
+
+  const eventProps = useCTA(onActivate);
+
+  return <li {...eventProps}>{name}</li>;
+};
+
+export const ClientList: React.FC<Props> = ({ clients }) => {
+  const onSelectClient = useCallback((name: string) => {
+    console.log('Selected client!', name);
+    // TODO
+  }, []);
+
+  return (
+    <div>
+      <h6>Client list</h6>
+      <ul>
+        {clients.map((name) => (
+          <ClientListItem key={name} name={name} onSelect={onSelectClient} />
+        ))}
+      </ul>
+    </div>
+  );
+};

+ 65 - 0
gmus/src/components/gmus/index.tsx

@@ -0,0 +1,65 @@
+import React, { useCallback, useEffect } from 'react';
+import { ActionTypeLocal } from '../../constants/actions';
+import { useGlobalState } from '../../hooks/reducer';
+
+import { useKeepalive } from '../../hooks/socket';
+import { MusicPlayer } from '../../types/state';
+import { ClientList } from '../client-list';
+
+export type Props = {
+  myClientName: string;
+  socket: WebSocket;
+};
+
+const testSongId = 7954; // TODO
+
+export const Gmus: React.FC<Props> = ({ myClientName, socket }) => {
+  useKeepalive(socket);
+
+  const [state, dispatch] = useGlobalState(socket);
+
+  const playSong = React.useCallback(() => {
+    dispatch({
+      type: ActionTypeLocal.StateSet,
+      payload: {
+        songId: testSongId,
+        playTimeSeconds: 0,
+        playing: true,
+        currentClient: myClientName,
+      },
+    });
+  }, [myClientName]);
+
+  const pauseSong = React.useCallback(() => {
+    setLocalState((last) => {
+      const next: MusicPlayer = { ...last, playing: false };
+
+      socket.send(
+        JSON.stringify({
+          type: 'STATE_SET',
+          payload: JSON.stringify(next),
+        }),
+      );
+
+      return next;
+    });
+  }, [ws]);
+
+  return (
+    <div>
+      <div>
+        <button onClick={playSong}>Play!</button>
+        <button onClick={pauseSong}>Pause!</button>
+      </div>
+      <ClientList clients={clientList} />
+      <div>
+        <h6>Local State</h6>
+        <pre>{JSON.stringify(localState, null, 2)}</pre>
+      </div>
+      <div>
+        <h6>Remote State</h6>
+        <pre>{JSON.stringify(remoteState, null, 2)}</pre>
+      </div>
+    </div>
+  );
+};

+ 97 - 0
gmus/src/components/identify/index.spec.tsx

@@ -0,0 +1,97 @@
+import { act, fireEvent, render } from '@testing-library/react';
+import React from 'react';
+import { Identify, Props } from './index';
+
+describe(Identify.name, () => {
+  const props: Props = {
+    connecting: false,
+    onIdentify: jest.fn(),
+  };
+
+  it('should render an input', () => {
+    expect.assertions(1);
+    const { getByDisplayValue } = render(<Identify {...props} />);
+    expect(getByDisplayValue('')).toBeInTheDocument();
+  });
+
+  it('should render a connect button', () => {
+    expect.assertions(1);
+    const { getByText } = render(<Identify {...props} />);
+    expect(getByText('Connect')).toBeInTheDocument();
+  });
+
+  describe('when pressing the connect button', () => {
+    it('should call the onIdentify prop', () => {
+      expect.assertions(2);
+      const { getByDisplayValue, getByText } = render(<Identify {...props} />);
+      const input = getByDisplayValue('');
+      const button = getByText('Connect');
+
+      act(() => {
+        fireEvent.change(input, { target: { value: 'my-computer' } });
+      });
+      act(() => {
+        fireEvent.click(button);
+      });
+
+      expect(props.onIdentify).toHaveBeenCalledTimes(1);
+      expect(props.onIdentify).toHaveBeenCalledWith('my-computer');
+    });
+  });
+
+  describe('when connecting', () => {
+    const propsConnecting: Props = { ...props, connecting: true };
+
+    it('should disable the connect button', () => {
+      expect.assertions(1);
+
+      const { getByText } = render(<Identify {...propsConnecting} />);
+      const button = getByText('Connect') as HTMLButtonElement;
+      expect(button.disabled).toBe(true);
+    });
+
+    it('should display a loading spinner', () => {
+      expect.assertions(1);
+
+      const { container } = render(<Identify {...propsConnecting} />);
+      expect(container).toMatchInlineSnapshot(`
+        <div>
+          <div>
+            <div>
+              <span>
+                Set client name:
+              </span>
+              <input
+                type="text"
+              />
+              <button
+                disabled=""
+              >
+                Connect
+              </button>
+              <div
+                class="css-1d9ls5a"
+              >
+                <div
+                  class="css-18jumju"
+                />
+                <div
+                  class="css-1ix8i79"
+                />
+                <div
+                  class="css-mdd9ud"
+                />
+                <div
+                  class="css-qbu4to"
+                />
+                <div
+                  class="css-1wtwgn9"
+                />
+              </div>
+            </div>
+          </div>
+        </div>
+      `);
+    });
+  });
+});

+ 31 - 0
gmus/src/components/identify/index.tsx

@@ -0,0 +1,31 @@
+import React, { useCallback, useState } from 'react';
+import { CircleLoader } from 'react-spinners';
+
+export type Props = {
+  connecting: boolean;
+  onIdentify: (name: string) => void;
+};
+
+export const Identify: React.FC<Props> = ({ connecting, onIdentify }) => {
+  const [name, setName] = useState<string>('');
+  const onChange = useCallback(
+    (event: React.ChangeEvent<HTMLInputElement>) => setName(event.target.value),
+    [],
+  );
+  const onConnect = useCallback(() => {
+    onIdentify(name);
+  }, [name, onIdentify]);
+
+  return (
+    <div>
+      <div>
+        <span>Set client name:</span>
+        <input type="text" onChange={onChange} />
+        <button onClick={onConnect} disabled={connecting}>
+          Connect
+        </button>
+        {connecting && <CircleLoader size={50} />}
+      </div>
+    </div>
+  );
+};

+ 7 - 167
gmus/src/components/root/index.tsx

@@ -1,175 +1,15 @@
 import React from 'react';
 
-const testSongId = 7954;
-const url = 'ws://localhost:3002/pubsub';
-
-type MusicPlayer = {
-  songId: number | null;
-  playing: boolean;
-  playTimeSeconds: number;
-  currentClient: string;
-};
-
-interface Action<T extends string = string, P = unknown> {
-  type: T;
-  payload: P;
-}
-
-enum ActionType {
-  StateSet = 'STATE_SET',
-  ClientsUpdated = 'CLIENTS_UPDATED',
-}
-
-type ActionStateSet = Action<ActionType.StateSet, MusicPlayer>;
-type ActionClientsUpdated = Action<ActionType.ClientsUpdated, string[]>;
-
-type AnyAction = ActionStateSet | ActionClientsUpdated;
-
-const initialState: MusicPlayer = {
-  songId: null,
-  playing: false,
-  playTimeSeconds: 0,
-  currentClient: '',
-};
-
-const WithClientName: React.FC<{
-  clientName: string;
-  onDisconnect: (err?: string) => void;
-}> = ({ clientName, onDisconnect }) => {
-  const [localState, setLocalState] = React.useState<MusicPlayer>(initialState);
-  const [remoteState, setRemoteState] = React.useState<MusicPlayer | null>(null);
-
-  const [clientList, setClientList] = React.useState<string[]>([]);
-
-  const [, setHasReceivedFirstState] = React.useState<boolean>(false);
-
-  const ws = React.useMemo(() => {
-    console.log('calling new WebSocket()');
-    return new WebSocket(`${url}?client-name=${clientName}`);
-  }, [clientName]);
-
-  React.useEffect(() => {
-    ws.onopen = () => {
-      console.log('WS opened!');
-    };
-
-    ws.onmessage = ({ data }) => {
-      try {
-        const action = JSON.parse(data) as AnyAction;
-        switch (action.type) {
-          case ActionType.StateSet:
-            setRemoteState(action.payload);
-            break;
-          case ActionType.ClientsUpdated:
-            setClientList(action.payload);
-            break;
-          default:
-        }
-
-        setHasReceivedFirstState(true);
-      } catch (err) {
-        console.warn('Error parsing message from websocket', err.message);
-      }
-    };
-
-    ws.onerror = (event: Event) => {
-      console.log('onerror!', event);
-      onDisconnect();
-    };
-    ws.onclose = () => onDisconnect();
-  }, [ws, onDisconnect]);
-
-  const playSong = React.useCallback(() => {
-    setLocalState((last) => {
-      const next: MusicPlayer = {
-        ...last,
-        songId: testSongId,
-        playTimeSeconds: 0,
-        playing: true,
-        currentClient: clientName,
-      };
-
-      ws.send(
-        JSON.stringify({
-          type: 'STATE_SET',
-          payload: JSON.stringify(next),
-        }),
-      );
-
-      return next;
-    });
-  }, [ws, clientName]);
-
-  const pauseSong = React.useCallback(() => {
-    setLocalState((last) => {
-      const next: MusicPlayer = { ...last, playing: false };
-
-      ws.send(
-        JSON.stringify({
-          type: 'STATE_SET',
-          payload: JSON.stringify(next),
-        }),
-      );
-
-      return next;
-    });
-  }, [ws]);
-
-  return (
-    <div>
-      <div>
-        <button onClick={playSong}>Play!</button>
-        <button onClick={pauseSong}>Pause!</button>
-      </div>
-      <div>
-        <h6>Client list</h6>
-        <ul>
-          {clientList.map((name) => (
-            <li key={name}>{name}</li>
-          ))}
-        </ul>
-      </div>
-      <div>
-        <h6>Local State</h6>
-        <pre>{JSON.stringify(localState, null, 2)}</pre>
-      </div>
-      <div>
-        <h6>Remote State</h6>
-        <pre>{JSON.stringify(remoteState, null, 2)}</pre>
-      </div>
-    </div>
-  );
-};
+import { useSocket } from '../../hooks/socket';
+import { Gmus } from '../gmus';
+import { Identify } from '../identify';
 
 export const Root: React.FC = () => {
-  const [{ name, shouldConnect }, setName] = React.useState<{
-    name: string | null;
-    shouldConnect: boolean;
-  }>({ name: null, shouldConnect: false });
-  const [error, setError] = React.useState<string | null>(null);
-
-  const onDisconnect = React.useCallback((err?: string) => {
-    setName({ name: null, shouldConnect: false });
-    setError(err ?? null);
-  }, []);
+  const { name, onIdentify, socket, connecting, connected, error } = useSocket();
 
-  if (error || !(name && shouldConnect)) {
-    return (
-      <div>
-        <div>
-          <span>Set client name:</span>
-          <input
-            type="text"
-            onChange={(event): void => setName({ name: event.target.value, shouldConnect: false })}
-          />
-          <button onClick={(): void => setName((last) => ({ ...last, shouldConnect: true }))}>
-            Connect
-          </button>
-        </div>
-        {error && <div>Error occurred: {error}</div>}
-      </div>
-    );
+  if (!(socket && connected && name) || error) {
+    return <Identify connecting={connecting} onIdentify={onIdentify} />;
   }
 
-  return <WithClientName clientName={name} onDisconnect={onDisconnect} />;
+  return <Gmus myClientName={name} socket={socket} />;
 };

+ 25 - 0
gmus/src/constants/actions.ts

@@ -0,0 +1,25 @@
+import { MusicPlayer } from '../types/state';
+
+interface Action<T extends string = string, P = unknown> {
+  type: T;
+  payload: P;
+}
+
+export enum ActionTypeRemote {
+  StateSet = 'STATE_SET',
+  ClientsUpdated = 'CLIENTS_UPDATED',
+}
+
+export enum ActionTypeLocal {
+  StateSet = 'LOCAL_STATE_SET',
+}
+
+export type ActionStateSetRemote = Action<ActionTypeRemote.StateSet, MusicPlayer | null>;
+export type ActionStateSetLocal = Action<ActionTypeLocal.StateSet, MusicPlayer>;
+
+export type ActionClientsUpdated = Action<ActionTypeRemote.ClientsUpdated, string[]>;
+
+export type LocalAction = ActionStateSetLocal;
+export type RemoteAction = ActionStateSetRemote | ActionClientsUpdated;
+
+export type AnyAction = LocalAction | RemoteAction;

+ 1 - 0
gmus/src/constants/system.ts

@@ -0,0 +1 @@
+export const socketKeepaliveTimeout = 20000;

+ 77 - 0
gmus/src/hooks/cta.spec.tsx

@@ -0,0 +1,77 @@
+import { render, RenderResult, act, fireEvent } from '@testing-library/react';
+import React from 'react';
+
+import { useCTA } from './cta';
+
+describe('useCTA', () => {
+  const onActivate = jest.fn();
+  const callback = jest.fn();
+
+  const TestComponent: React.FC = () => {
+    const result = useCTA(onActivate);
+    React.useEffect(() => {
+      callback(result);
+    }, [result]);
+
+    return <button {...result}>CTA button</button>;
+  };
+
+  const setup = (): RenderResult => render(<TestComponent />);
+
+  describe('onClick event', () => {
+    it('should be returned', () => {
+      expect.assertions(1);
+      setup();
+      expect(callback).toHaveBeenCalledWith(
+        expect.objectContaining({
+          onClick: expect.any(Function),
+        }),
+      );
+    });
+
+    it('should call onActivate when run', () => {
+      expect.assertions(1);
+      const { getByRole } = setup();
+      const button = getByRole('button');
+      act(() => {
+        fireEvent.click(button);
+      });
+
+      expect(onActivate).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('onKeyDown event', () => {
+    it('should be returned', () => {
+      expect.assertions(1);
+      setup();
+      expect(callback).toHaveBeenCalledWith(
+        expect.objectContaining({
+          onKeyDown: expect.any(Function),
+        }),
+      );
+    });
+
+    it('should call onActivate when run with the enter key', () => {
+      expect.assertions(1);
+      const { getByRole } = setup();
+      const button = getByRole('button');
+      act(() => {
+        fireEvent.keyDown(button, { key: 'Enter' });
+      });
+
+      expect(onActivate).toHaveBeenCalledTimes(1);
+    });
+
+    it('should not call onActivate when run without the enter key', () => {
+      expect.assertions(1);
+      const { getByRole } = setup();
+      const button = getByRole('button');
+      act(() => {
+        fireEvent.keyDown(button, { key: 'F' });
+      });
+
+      expect(onActivate).not.toHaveBeenCalled();
+    });
+  });
+});

+ 36 - 0
gmus/src/hooks/cta.ts

@@ -0,0 +1,36 @@
+import React, { ReactEventHandler, useMemo } from 'react';
+
+export const isEnter = (event: KeyboardEvent | React.KeyboardEvent): boolean =>
+  event.key === 'Enter';
+
+type Options = {
+  stopPropagation?: boolean;
+};
+
+export function useCTA<E extends HTMLElement = HTMLElement>(
+  onActivate: () => void,
+  options?: Options,
+): {
+  onClick: ReactEventHandler<E>;
+  onKeyDown: ReactEventHandler<E>;
+} {
+  const stopPropagation = !!options?.stopPropagation;
+  const events = useMemo(
+    () => ({
+      onKeyDown: (event: React.KeyboardEvent<E>): void => {
+        if (isEnter(event)) {
+          onActivate();
+        }
+      },
+      onClick: (event: React.MouseEvent<E>): void => {
+        if (stopPropagation) {
+          event.stopPropagation();
+        }
+        onActivate();
+      },
+    }),
+    [onActivate, stopPropagation],
+  );
+
+  return events;
+}

+ 92 - 0
gmus/src/hooks/reducer.ts

@@ -0,0 +1,92 @@
+import { Dispatch, Reducer, useCallback, useEffect, useReducer } from 'react';
+import {
+  ActionType,
+  ActionTypeLocal,
+  ActionTypeRemote,
+  AnyAction,
+  RemoteAction,
+} from '../constants/actions';
+import { MusicPlayer } from '../types/state';
+
+export type GlobalState = {
+  lastAction: AnyAction | null;
+  localPlayer: MusicPlayer;
+  remotePlayer: MusicPlayer | null;
+  clientList: string[];
+};
+
+const initialState: GlobalState = {
+  lastAction: null,
+  localPlayer: {
+    songId: null,
+    playing: false,
+    playTimeSeconds: 0,
+    currentClient: '',
+  },
+  remotePlayer: null,
+  clientList: [],
+};
+
+function init(state: GlobalState): GlobalState {
+  return state;
+}
+
+function globalReducer(state: GlobalState, action: AnyAction): GlobalState {
+  switch (action.type) {
+    case ActionTypeRemote.StateSet:
+      return { ...state, remotePlayer: action.payload };
+    case ActionTypeRemote.ClientsUpdated:
+      return { ...state, clientList: action.payload };
+    default:
+      return state;
+  }
+}
+
+function globalEffects(lastAction: AnyAction | null): RemoteAction | null {
+  if (!lastAction) {
+    return null;
+  }
+
+  switch (lastAction.type) {
+    case ActionTypeLocal.StateSet:
+      return { type: ActionTypeRemote.StateSet, payload: lastAction.payload };
+    default:
+      return null;
+  }
+}
+
+export function useGlobalState(socket: WebSocket): [GlobalState, Dispatch<AnyAction>] {
+  const [state, dispatch] = useReducer<Reducer<GlobalState, AnyAction>, GlobalState>(
+    globalReducer,
+    initialState,
+    init,
+  );
+
+  const onMessage = useCallback(({ data }: { data: string }): void => {
+    try {
+      const action = JSON.parse(data) as AnyAction;
+      dispatch(action);
+    } catch (err) {
+      console.warn('Error parsing message from websocket', err.message);
+    }
+  }, []);
+
+  useEffect(() => {
+    // eslint-disable-next-line no-param-reassign
+    socket.onmessage = onMessage;
+  }, [socket, onMessage]);
+
+  useEffect(() => {
+    const remoteEffect = globalEffects(state.lastAction);
+    if (remoteEffect) {
+      socket.send(
+        JSON.stringify({
+          type: remoteEffect.type,
+          payload: JSON.stringify(remoteEffect.payload),
+        }),
+      );
+    }
+  }, [socket, state.lastAction]);
+
+  return [state, dispatch];
+}

+ 74 - 0
gmus/src/hooks/socket.ts

@@ -0,0 +1,74 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useStorageState } from 'react-storage-hooks';
+
+import { socketKeepaliveTimeout } from '../constants/system';
+
+const url = 'ws://localhost:3002/pubsub'; // TODO
+
+export function useSocket(): {
+  name: string | null;
+  onIdentify: (name: string) => void;
+  socket: WebSocket | null;
+  error: boolean;
+  connecting: boolean;
+  connected: boolean;
+} {
+  const [name, saveName] = useStorageState<string>(localStorage, 'client-name', '');
+
+  const [socket, setSocket] = useState<WebSocket | null>(null);
+  const [error, setError] = useState<boolean>(false);
+
+  const [connecting, setConnecting] = useState<boolean>(false);
+
+  const onIdentify = useCallback(
+    (newName: string) => {
+      setConnecting(true);
+
+      const ws = new WebSocket(`${url}?client-name=${name}`);
+
+      ws.onopen = (): void => {
+        if (ws.readyState === ws.OPEN) {
+          setError(false);
+          setConnecting(false);
+
+          saveName(newName);
+
+          setSocket(ws);
+        }
+      };
+
+      ws.onclose = (): void => {
+        setError(false);
+        setSocket(null);
+      };
+    },
+    [saveName],
+  );
+
+  return {
+    name,
+    onIdentify,
+    socket,
+    error,
+    connecting,
+    connected: socket?.readyState === socket?.OPEN,
+  };
+}
+
+export function useKeepalive(socket: WebSocket): void {
+  const keepalive = useRef<number>();
+
+  useEffect(() => {
+    keepalive.current = window.setInterval(() => {
+      if (socket.readyState === socket.OPEN) {
+        socket.send(JSON.stringify({ type: 'PING' }));
+      } else {
+        socket.close();
+      }
+    }, socketKeepaliveTimeout);
+
+    return (): void => {
+      clearInterval(keepalive.current);
+    };
+  }, [socket]);
+}

+ 1 - 1
gmus/src/reportWebVitals.ts

@@ -1,6 +1,6 @@
 import { ReportHandler } from 'web-vitals';
 
-const reportWebVitals = (onPerfEntry?: ReportHandler) => {
+const reportWebVitals = (onPerfEntry?: ReportHandler): void => {
   if (onPerfEntry && onPerfEntry instanceof Function) {
     import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
       getCLS(onPerfEntry);

+ 6 - 0
gmus/src/types/state.ts

@@ -0,0 +1,6 @@
+export type MusicPlayer = {
+  songId: number | null;
+  playing: boolean;
+  playTimeSeconds: number;
+  currentClient: string;
+};

+ 162 - 4
gmus/yarn.lock

@@ -1148,6 +1148,83 @@
   resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-10.1.0.tgz#f0950bba18819512d42f7197e56c518aa491cf18"
   integrity sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg==
 
+"@emotion/cache@^10.0.27":
+  version "10.0.29"
+  resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0"
+  integrity sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==
+  dependencies:
+    "@emotion/sheet" "0.9.4"
+    "@emotion/stylis" "0.8.5"
+    "@emotion/utils" "0.11.3"
+    "@emotion/weak-memoize" "0.2.5"
+
+"@emotion/core@^10.0.15":
+  version "10.1.1"
+  resolved "https://registry.yarnpkg.com/@emotion/core/-/core-10.1.1.tgz#c956c1365f2f2481960064bcb8c4732e5fb612c3"
+  integrity sha512-ZMLG6qpXR8x031NXD8HJqugy/AZSkAuMxxqB46pmAR7ze47MhNJ56cdoX243QPZdGctrdfo+s08yZTiwaUcRKA==
+  dependencies:
+    "@babel/runtime" "^7.5.5"
+    "@emotion/cache" "^10.0.27"
+    "@emotion/css" "^10.0.27"
+    "@emotion/serialize" "^0.11.15"
+    "@emotion/sheet" "0.9.4"
+    "@emotion/utils" "0.11.3"
+
+"@emotion/css@^10.0.27":
+  version "10.0.27"
+  resolved "https://registry.yarnpkg.com/@emotion/css/-/css-10.0.27.tgz#3a7458198fbbebb53b01b2b87f64e5e21241e14c"
+  integrity sha512-6wZjsvYeBhyZQYNrGoR5yPMYbMBNEnanDrqmsqS1mzDm1cOTu12shvl2j4QHNS36UaTE0USIJawCH9C8oW34Zw==
+  dependencies:
+    "@emotion/serialize" "^0.11.15"
+    "@emotion/utils" "0.11.3"
+    babel-plugin-emotion "^10.0.27"
+
+"@emotion/hash@0.8.0":
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413"
+  integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==
+
+"@emotion/memoize@0.7.4":
+  version "0.7.4"
+  resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb"
+  integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==
+
+"@emotion/serialize@^0.11.15", "@emotion/serialize@^0.11.16":
+  version "0.11.16"
+  resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-0.11.16.tgz#dee05f9e96ad2fb25a5206b6d759b2d1ed3379ad"
+  integrity sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==
+  dependencies:
+    "@emotion/hash" "0.8.0"
+    "@emotion/memoize" "0.7.4"
+    "@emotion/unitless" "0.7.5"
+    "@emotion/utils" "0.11.3"
+    csstype "^2.5.7"
+
+"@emotion/sheet@0.9.4":
+  version "0.9.4"
+  resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-0.9.4.tgz#894374bea39ec30f489bbfc3438192b9774d32e5"
+  integrity sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==
+
+"@emotion/stylis@0.8.5":
+  version "0.8.5"
+  resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.8.5.tgz#deacb389bd6ee77d1e7fcaccce9e16c5c7e78e04"
+  integrity sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==
+
+"@emotion/unitless@0.7.5":
+  version "0.7.5"
+  resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.7.5.tgz#77211291c1900a700b8a78cfafda3160d76949ed"
+  integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==
+
+"@emotion/utils@0.11.3":
+  version "0.11.3"
+  resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.11.3.tgz#a759863867befa7e583400d322652a3f44820924"
+  integrity sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==
+
+"@emotion/weak-memoize@0.2.5":
+  version "0.2.5"
+  resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46"
+  integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==
+
 "@eslint/eslintrc@^0.2.1":
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.2.1.tgz#f72069c330461a06684d119384435e12a5d76e3c"
@@ -2548,6 +2625,22 @@ babel-plugin-dynamic-import-node@^2.3.3:
   dependencies:
     object.assign "^4.1.0"
 
+babel-plugin-emotion@^10.0.27:
+  version "10.0.33"
+  resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-10.0.33.tgz#ce1155dcd1783bbb9286051efee53f4e2be63e03"
+  integrity sha512-bxZbTTGz0AJQDHm8k6Rf3RQJ8tX2scsfsRyKVgAbiUPUNIRtlK+7JxP+TAd1kRLABFxe0CFm2VdK4ePkoA9FxQ==
+  dependencies:
+    "@babel/helper-module-imports" "^7.0.0"
+    "@emotion/hash" "0.8.0"
+    "@emotion/memoize" "0.7.4"
+    "@emotion/serialize" "^0.11.16"
+    babel-plugin-macros "^2.0.0"
+    babel-plugin-syntax-jsx "^6.18.0"
+    convert-source-map "^1.5.0"
+    escape-string-regexp "^1.0.5"
+    find-root "^1.1.0"
+    source-map "^0.5.7"
+
 babel-plugin-istanbul@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.0.0.tgz#e159ccdc9af95e0b570c75b4573b7c34d671d765"
@@ -2569,7 +2662,7 @@ babel-plugin-jest-hoist@^26.6.2:
     "@types/babel__core" "^7.0.0"
     "@types/babel__traverse" "^7.0.6"
 
-babel-plugin-macros@2.8.0:
+babel-plugin-macros@2.8.0, babel-plugin-macros@^2.0.0:
   version "2.8.0"
   resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz#0f958a7cc6556b1e65344465d99111a1e5e10138"
   integrity sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==
@@ -2583,6 +2676,11 @@ babel-plugin-named-asset-import@^0.3.7:
   resolved "https://registry.yarnpkg.com/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.7.tgz#156cd55d3f1228a5765774340937afc8398067dd"
   integrity sha512-squySRkf+6JGnvjoUtDEjSREJEBirnXi9NqP6rjSYsylxQxqBTz+pkmf395i9E2zsvmYUaI40BHo6SqZUdydlw==
 
+babel-plugin-syntax-jsx@^6.18.0:
+  version "6.18.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946"
+  integrity sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY=
+
 babel-plugin-syntax-object-rest-spread@^6.8.0:
   version "6.13.0"
   resolved "https://registry.yarnpkg.com/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5"
@@ -3427,7 +3525,7 @@ content-type@~1.0.4:
   resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
   integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
 
-convert-source-map@1.7.0, convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0:
+convert-source-map@1.7.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0:
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"
   integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==
@@ -3834,6 +3932,11 @@ cssstyle@^2.2.0:
   dependencies:
     cssom "~0.3.6"
 
+csstype@^2.5.7:
+  version "2.6.14"
+  resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.14.tgz#004822a4050345b55ad4dcc00be1d9cf2f4296de"
+  integrity sha512-2mSc+VEpGPblzAxyeR+vZhJKgYg0Og0nnRi7pmRXFYYxSfnOnW8A5wwQb4n4cE2nIOzqKOAzLCaEX6aBmNEv8A==
+
 csstype@^3.0.2:
   version "3.0.5"
   resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.5.tgz#7fdec6a28a67ae18647c51668a9ff95bb2fa7bb8"
@@ -4422,6 +4525,20 @@ escodegen@^1.14.1:
   optionalDependencies:
     source-map "~0.6.1"
 
+eslint-config-airbnb-base@^14.2.1:
+  version "14.2.1"
+  resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.1.tgz#8a2eb38455dc5a312550193b319cdaeef042cd1e"
+  integrity sha512-GOrQyDtVEc1Xy20U7vsB2yAoB4nBlfH5HZJeatRXHleO+OS5Ot+MWij4Dpltw4/DyIkqUfqz1epfhVR5XWWQPA==
+  dependencies:
+    confusing-browser-globals "^1.0.10"
+    object.assign "^4.1.2"
+    object.entries "^1.1.2"
+
+eslint-config-prettier@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-7.0.0.tgz#c1ae4106f74e6c0357f44adb076771d032ac0e97"
+  integrity sha512-8Y8lGLVPPZdaNA7JXqnvETVC7IiVRgAP6afQu9gOQRn90YY3otMNh+x7Vr2vMePQntF+5erdSUBqSzCmU/AxaQ==
+
 eslint-config-react-app@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/eslint-config-react-app/-/eslint-config-react-app-6.0.0.tgz#ccff9fc8e36b322902844cbd79197982be355a0e"
@@ -4496,6 +4613,13 @@ eslint-plugin-jsx-a11y@^6.3.1:
     jsx-ast-utils "^3.1.0"
     language-tags "^1.0.5"
 
+eslint-plugin-prettier@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.3.0.tgz#61e295349a65688ffac0b7808ef0a8244bdd8d40"
+  integrity sha512-tMTwO8iUWlSRZIwS9k7/E4vrTsfvsrcM5p1eftyuqWH25nKsz/o6/54I7jwQ/3zobISyC7wMy9ZsFwgTxOcOpQ==
+  dependencies:
+    prettier-linter-helpers "^1.0.0"
+
 eslint-plugin-react-hooks@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.2.0.tgz#8c229c268d468956334c943bb45fc860280f5556"
@@ -4850,6 +4974,11 @@ fast-deep-equal@^3.1.1:
   resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
   integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
 
+fast-diff@^1.1.2:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
+  integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
+
 fast-glob@^3.1.1:
   version "3.2.4"
   resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.4.tgz#d20aefbf99579383e7f3cc66529158c9b98554d3"
@@ -4978,6 +5107,11 @@ find-cache-dir@^3.3.1:
     make-dir "^3.0.2"
     pkg-dir "^4.1.0"
 
+find-root@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4"
+  integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==
+
 find-up@4.1.0, find-up@^4.0.0, find-up@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
@@ -7533,7 +7667,7 @@ object-visit@^1.0.0:
   dependencies:
     isobject "^3.0.0"
 
-object.assign@^4.1.0, object.assign@^4.1.1:
+object.assign@^4.1.0, object.assign@^4.1.1, object.assign@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940"
   integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==
@@ -8694,6 +8828,18 @@ prepend-http@^1.0.0:
   resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
   integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
 
+prettier-linter-helpers@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b"
+  integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==
+  dependencies:
+    fast-diff "^1.1.2"
+
+prettier@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5"
+  integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==
+
 pretty-bytes@^5.3.0:
   version "5.4.1"
   resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.4.1.tgz#cd89f79bbcef21e3d21eb0da68ffe93f803e884b"
@@ -9043,6 +9189,18 @@ react-scripts@4.0.1:
   optionalDependencies:
     fsevents "^2.1.3"
 
+react-spinners@^0.9.0:
+  version "0.9.0"
+  resolved "https://registry.yarnpkg.com/react-spinners/-/react-spinners-0.9.0.tgz#b22c38acbfce580cd6f1b04a4649e812370b1fb8"
+  integrity sha512-+x6eD8tn/aYLdxZjNW7fSR1uoAXLb9qq6TFYZR1dFweJvckcf/HfP8Pa/cy5HOvB/cvI4JgrYXTjh2Me3S6Now==
+  dependencies:
+    "@emotion/core" "^10.0.15"
+
+react-storage-hooks@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/react-storage-hooks/-/react-storage-hooks-4.0.1.tgz#e30ed5cda48c77c431ecc02ec3824bd615f5b7fb"
+  integrity sha512-fetDkT5RDHGruc2NrdD1iqqoLuXgbx6AUpQSQLLkrCiJf8i97EtwJNXNTy3+GRfsATLG8TZgNc9lGRZOaU5yQA==
+
 react@^17.0.1:
   version "17.0.1"
   resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127"
@@ -9898,7 +10056,7 @@ source-map@0.6.1, source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, sourc
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
   integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
 
-source-map@^0.5.0, source-map@^0.5.6:
+source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7:
   version "0.5.7"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
   integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=