Bläddra i källkod

feat: seekable playhead

Fela Maslen 5 år sedan
förälder
incheckning
765d5dc80e

+ 86 - 18
gmus-web/src/components/ui/mobile/info.tsx

@@ -1,22 +1,26 @@
 import { rem } from 'polished';
-import React from 'react';
+import React, { useCallback, useRef } from 'react';
 import styled from 'styled-components';
 
-import { Song } from '../../../types';
+import { MusicPlayer, Song } from '../../../types';
+import { formatTime } from '../../../utils/time';
 
 export type Props = {
   song: Song | null;
+  player: MusicPlayer;
+  onSeek: (time: number) => void;
 };
 
 const Container = styled.div`
   align-items: center;
   background: #e9fff3;
   border: 1px solid #339a33;
+  border-radius: ${rem(4)};
   display: flex;
   flex-flow: column;
   justify-content: center;
   margin: ${rem(8)} ${rem(16)};
-  padding: ${rem(8)};
+  padding: ${rem(8)} 0 0 0;
 `;
 
 const Title = styled.span`
@@ -32,18 +36,82 @@ const Dash = styled.span`
   margin: 0 ${rem(12)};
 `;
 
-export const SongInfo: React.FC<Props> = ({ song }) => (
-  <Container>
-    {song && (
-      <>
-        <Title>{song.title}</Title>
-        <Meta>
-          <Artist>{song.artist || 'Unknown Artist'}</Artist>
-          <Dash>-</Dash>
-          <Album>{song.album || 'Unknown Album'}</Album>
-        </Meta>
-      </>
-    )}
-    {!song && <Dash>-</Dash>}
-  </Container>
-);
+const Seeker = styled.div`
+  background: rgba(0, 0, 0, 0.5);
+  color: white;
+  display: flex;
+  flex: 0 0 ${rem(16)};
+  margin-top: ${rem(8)};
+  width: 100%;
+`;
+
+const Time = styled.span`
+  align-items: center;
+  display: flex;
+  flex: 0 0 auto;
+  font-size: ${rem(12)};
+  height: 100%;
+  padding: 0 ${rem(8)};
+`;
+
+const TotalTime = styled(Time)`
+  text-align: right;
+`;
+
+const Gutter = styled.div`
+  display: flex;
+  flex: 1;
+  height: 100%;
+`;
+
+const Progress = styled.div`
+  align-items: flex-end;
+  display: flex;
+  justify-content: flex-end;
+`;
+
+const PlayHead = styled.span`
+  background: white;
+  flex: 0 0 ${rem(2)};
+  height: 100%;
+`;
+
+export const SongInfo: React.FC<Props> = ({ song, player, onSeek }) => {
+  const gutter = useRef<HTMLDivElement>(null);
+  const seekToTouch = useCallback(
+    (event: React.TouchEvent) => {
+      if (!(gutter.current && song?.time && event.changedTouches.length)) {
+        return;
+      }
+      const fraction =
+        (event.changedTouches[0].pageX - gutter.current.offsetLeft) / gutter.current.offsetWidth;
+      onSeek(song.time * fraction);
+    },
+    [onSeek, song?.time],
+  );
+
+  return (
+    <Container>
+      {song && (
+        <>
+          <Title>{song.title}</Title>
+          <Meta>
+            <Artist>{song.artist || 'Unknown Artist'}</Artist>
+            <Dash>-</Dash>
+            <Album>{song.album || 'Unknown Album'}</Album>
+          </Meta>
+          <Seeker>
+            <Time>{formatTime(player.currentTime)}</Time>
+            <Gutter ref={gutter} onTouchEnd={seekToTouch}>
+              <Progress style={{ flexBasis: `${(100 * player.currentTime) / song.time}%` }}>
+                <PlayHead />
+              </Progress>
+            </Gutter>
+            <TotalTime>{formatTime(song.time)}</TotalTime>
+          </Seeker>
+        </>
+      )}
+      {!song && <Dash>-</Dash>}
+    </Container>
+  );
+};

+ 3 - 2
gmus-web/src/components/ui/mobile/wrapper.tsx

@@ -1,5 +1,5 @@
 import React, { useCallback, useContext } from 'react';
-import { playPaused } from '../../../actions';
+import { playPaused, seeked } from '../../../actions';
 
 import { DispatchContext, StateContext } from '../../../context/state';
 import { UIProviderComponent } from '../types';
@@ -13,10 +13,11 @@ export const MobileUIProvider: UIProviderComponent = ({ prevSong, nextSong, curr
   const state = useContext(StateContext);
 
   const onPlayPause = useCallback(() => dispatch(playPaused()), [dispatch]);
+  const onSeek = useCallback((time: number) => dispatch(seeked(time)), [dispatch]);
 
   return (
     <Styled.Container>
-      <SongInfo song={currentSong} />
+      <SongInfo song={currentSong} player={state.player} onSeek={onSeek} />
       <Buttons
         playing={state.player.playing}
         onPrev={prevSong}

+ 6 - 5
gmus-web/src/reducer/reducer.spec.ts

@@ -330,14 +330,14 @@ describe(globalReducer.name, () => {
         myClientName: 'some-master-client',
       };
 
-      it('should set the seek time', () => {
+      it('should set the current and seek time', () => {
         expect.assertions(1);
         const result = globalReducer(stateMaster, action);
 
         expect(result.player).toStrictEqual<MusicPlayer>({
           songId: 123,
           playing: true,
-          currentTime: 31,
+          currentTime: 173,
           seekTime: 173,
           master: 'some-master-client',
           queue: [],
@@ -356,11 +356,12 @@ describe(globalReducer.name, () => {
         myClientName: 'my-client',
       };
 
-      it('should not update the player', () => {
-        expect.assertions(1);
+      it('should only set the current time (optimistically)', () => {
+        expect.assertions(2);
         const result = globalReducer(stateSlave, action);
 
-        expect(result.player).toBe(stateSlave.player);
+        expect(result.player.seekTime).toBe(stateSlave.player.seekTime);
+        expect(result.player.currentTime).toBe(173);
       });
     });
   });

+ 8 - 4
gmus-web/src/reducer/reducer.ts

@@ -90,10 +90,14 @@ export function globalReducer(state: GlobalState, action: AnyAction): GlobalStat
       return onLocalStateSet(state, action);
 
     case ActionTypeLocal.Seeked:
-      if (!isMaster(state)) {
-        return state;
-      }
-      return { ...state, player: { ...state.player, seekTime: action.payload } };
+      return {
+        ...state,
+        player: {
+          ...state.player,
+          currentTime: action.payload,
+          seekTime: isMaster(state) ? action.payload : state.player.seekTime,
+        },
+      };
 
     case ActionTypeLocal.MasterSet:
       if (action.payload) {