فهرست منبع

feat: only update currentTime from automatic action

    * chore: updated tests
    * feat: only update current time on time update
    * chore: add optional priority to local state set actions
    * feat: priority 0 on actions from web client
    * feat: priority 0 on actions from mobile client
    * feat(api): priority property on actions
    * chore: updated go mod file
Fela Maslen 4 سال پیش
والد
کامیت
c3729385ad

+ 3 - 3
gmus-backend/go.mod

@@ -22,11 +22,11 @@ require (
 	github.com/joho/godotenv v1.3.0
 	github.com/leodido/go-urn v1.2.1 // indirect
 	github.com/lib/pq v1.3.0
-	github.com/nxadm/tail v1.4.5 // indirect
-	github.com/onsi/ginkgo v1.14.2
+	github.com/onsi/ginkgo v1.16.2
 	github.com/onsi/gomega v1.10.1
 	github.com/rs/cors v1.7.0
 	github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc // indirect
-	golang.org/x/sys v0.0.0-20201214095126-aec9a390925b // indirect
+	golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6 // indirect
+	golang.org/x/tools v0.1.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 )

+ 18 - 0
gmus-backend/go.sum

@@ -118,6 +118,8 @@ github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG
 github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
 github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
+github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
 github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM=
 github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4/go.mod h1:4Fw1eo5iaEhDUs8XyuhSVCVy52Jq3L+/3GJgYkwc+/0=
 github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
@@ -284,12 +286,16 @@ github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
 github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
 github.com/nxadm/tail v1.4.5 h1:obHEce3upls1IBn1gTw/o7bCv7OJb6Ib/o7wNO+4eKw=
 github.com/nxadm/tail v1.4.5/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
+github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
+github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
 github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
 github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
 github.com/onsi/ginkgo v1.14.2 h1:8mVmC9kjFFmA8H4pKMUhcblgifdkOIXPvbhN1T36q1M=
 github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
+github.com/onsi/ginkgo v1.16.2 h1:HFB2fbVIlhIfCfOW81bZFbiC/RvnpXSdhbF2/DJr134=
+github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E=
 github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
 github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
 github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE=
@@ -344,6 +350,7 @@ github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/gopher-lua v0.0.0-20191220021717-ab39c6098bdb h1:ZkM6LRnq40pR1Ox0hTHlnpkcOTuFIDQpZ1IN8rKKhX0=
 github.com/yuin/gopher-lua v0.0.0-20191220021717-ab39c6098bdb/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ=
 github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
@@ -434,6 +441,8 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R
 golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc h1:zK/HqS5bZxDptfPJNq8v7vJfXtkU7r9TLIoSr1bXaP4=
 golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974 h1:IX6qOQeG5uLjB/hjjwjedwfjND0hgjPMMyO1RoIXQNI=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -448,6 +457,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -486,8 +496,13 @@ golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200817155316-9781c653f443 h1:X18bCaipMcoJGm27Nv7zr4XYPKGUy92GtqboKC2Hxaw=
 golang.org/x/sys v0.0.0-20200817155316-9781c653f443/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201214095126-aec9a390925b h1:tv7/y4pd+sR8bcNb2D6o7BNU6zjWm0VjQLac+w7fNNM=
 golang.org/x/sys v0.0.0-20201214095126-aec9a390925b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6 h1:cdsMqa2nXzqlgs183pHxtvoVwU7CyzaCTAUOg94af4c=
+golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -547,6 +562,9 @@ golang.org/x/tools v0.0.0-20200806022845-90696ccdc692/go.mod h1:njjCfa9FT2d7l9Bc
 golang.org/x/tools v0.0.0-20200814230902-9882f1d1823d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200817023811-d00afeaade8f/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200818005847-188abfa75333/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
+golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
 golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

+ 1 - 0
gmus-backend/pkg/server/actions.go

@@ -20,6 +20,7 @@ const (
 type Action struct {
 	Type       ActionType  `json:"type"`
 	FromClient *string     `json:"fromClient"`
+	Priority   *int        `json:"priority"`
 	Payload    interface{} `json:"payload"`
 }
 

+ 2 - 0
gmus-mobile/lib/controller.dart

@@ -94,6 +94,7 @@ class Controller extends GetxController {
     if (!this._isMaster()) {
       this.socket.dispatch(jsonEncode({
         'type': actions.STATE_SET,
+        'priority': 0,
         'payload': this.player.value.stringify(),
       }));
     }
@@ -111,6 +112,7 @@ class Controller extends GetxController {
 
     this.socket.dispatch(jsonEncode({
       'type': actions.STATE_SET,
+      'priority': 0,
       'payload': this.player.value.stringify(),
     }));
   }

+ 4 - 4
gmus-mobile/test/controller_test.dart

@@ -69,7 +69,7 @@ void main() {
 
             controller.playPause();
 
-            verify(controller.socket.channel.sink.add('{"type":"STATE_SET","payload":{"songId":null,"playing":true,"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","activeClients":[],"queue":[],"shuffleMode":false}}')).called(1);
+            verify(controller.socket.channel.sink.add('{"type":"STATE_SET","priority":0,"payload":{"songId":null,"playing":true,"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","activeClients":[],"queue":[],"shuffleMode":false}}')).called(1);
           });
         });
 
@@ -81,7 +81,7 @@ void main() {
 
             controller.playPause();
 
-            verify(controller.socket.channel.sink.add('{"type":"STATE_SET","payload":{"songId":182,"playing":false,"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","activeClients":[],"queue":[],"shuffleMode":false}}')).called(1);
+            verify(controller.socket.channel.sink.add('{"type":"STATE_SET","priority":0,"payload":{"songId":182,"playing":false,"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","activeClients":[],"queue":[],"shuffleMode":false}}')).called(1);
           });
         });
       });
@@ -92,7 +92,7 @@ void main() {
           controller.player.value.playing = false;
           controller.playPause();
 
-          verifyNever(controller.socket.channel.sink.add('{"type":"STATE_SET","payload":{"songId":null,"playing":true,"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","activeClients":[],"queue":[],"shuffleMode":false}}'));
+          verifyNever(controller.socket.channel.sink.add('{"type":"STATE_SET","priority":0,"payload":{"songId":null,"playing":true,"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","activeClients":[],"queue":[],"shuffleMode":false}}'));
         });
       });
     });
@@ -104,7 +104,7 @@ void main() {
 
           controller.playSong(871);
 
-          verify(controller.socket.channel.sink.add('{"type":"STATE_SET","payload":{"songId":871,"playing":true,"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","activeClients":[],"queue":[],"shuffleMode":false}}')).called(1);
+          verify(controller.socket.channel.sink.add('{"type":"STATE_SET","priority":0,"payload":{"songId":871,"playing":true,"currentTime":0.0,"seekTime":-1.0,"master":"other-client-name-master","activeClients":[],"queue":[],"shuffleMode":false}}')).called(1);
         });
       });
 

+ 15 - 10
gmus-web/src/actions/actions.ts

@@ -1,8 +1,15 @@
-import { SetStateAction } from 'react';
-import { Song } from '../types';
-import { Member, MusicPlayer } from '../types/state';
-import { ActionErrorOccurred } from './error';
-import { ActionLocal, ActionRemote, ActionTypeLocal, ActionTypeRemote } from './types';
+import type { SetStateAction } from 'react';
+
+import type { Song } from '../types';
+import type { Member, MusicPlayer } from '../types/state';
+import type { ActionErrorOccurred } from './error';
+import {
+  ActionLocal,
+  ActionRemote,
+  ActionTypeLocal,
+  ActionTypeRemote,
+  LocalStateSetPayload,
+} from './types';
 
 export * from './types';
 
@@ -22,16 +29,14 @@ export const nameSet = (name: string): ActionNameSet => ({
   payload: name,
 });
 
-export type ActionStateSetLocal = ActionLocal<
-  ActionTypeLocal.StateSet,
-  SetStateAction<Omit<Partial<MusicPlayer>, 'seekTime'>>
->;
+export type ActionStateSetLocal = ActionLocal<ActionTypeLocal.StateSet, LocalStateSetPayload>;
 
 export const stateSet = (
   payload: SetStateAction<Partial<MusicPlayer>> = {},
+  priority = 0,
 ): ActionStateSetLocal => ({
   type: ActionTypeLocal.StateSet,
-  payload,
+  payload: { payload, priority },
 });
 
 export type ActionSeeked = ActionLocal<ActionTypeLocal.Seeked, number>;

+ 10 - 1
gmus-web/src/actions/types.ts

@@ -1,3 +1,7 @@
+import type { SetStateAction } from 'react';
+
+import type { MusicPlayer } from '../types';
+
 // Remote actions - these only come FROM the socket
 export enum ActionTypeRemote {
   StateSet = 'STATE_SET',
@@ -29,6 +33,11 @@ export interface Action<T extends string = string, P = unknown> {
 export type ActionRemote<T extends ActionTypeRemote = ActionTypeRemote, P = unknown> = Action<
   T,
   P
-> & { fromClient?: string | null };
+> & { priority: number; fromClient?: string | null };
 
 export type ActionLocal<T extends ActionTypeLocal = ActionTypeLocal, P = unknown> = Action<T, P>;
+
+export type LocalStateSetPayload = {
+  payload: SetStateAction<Partial<MusicPlayer>>;
+  priority?: number;
+};

+ 1 - 1
gmus-web/src/components/app.tsx

@@ -45,7 +45,7 @@ export const App: React.FC<Props> = ({
 
   const onTimeUpdate = useCallback(
     (currentTime: number): void => {
-      dispatch(stateSet({ currentTime }));
+      dispatch(stateSet({ currentTime }, 1));
     },
     [dispatch],
   );

+ 13 - 1
gmus-web/src/effects/effects.spec.ts

@@ -38,12 +38,13 @@ describe(globalEffects.name, () => {
         shuffleMode: false,
       };
 
-      const action = stateSet(localPlayer);
+      const action = stateSet(localPlayer, 3);
 
       const result = globalEffects(state, action);
 
       expect(result).toStrictEqual<ActionStateSetRemote>({
         type: ActionTypeRemote.StateSet,
+        priority: 3,
         payload: { ...state.player, ...localPlayer },
       });
     });
@@ -85,6 +86,7 @@ describe(globalEffects.name, () => {
 
         expect(result).toStrictEqual<ActionStateSetRemote>({
           type: ActionTypeRemote.StateSet,
+          priority: 0,
           payload: { ...state.player, seekTime: 776 },
         });
       });
@@ -116,6 +118,7 @@ describe(globalEffects.name, () => {
 
       expect(result).toStrictEqual<ActionStateSetRemote>({
         type: ActionTypeRemote.StateSet,
+        priority: 0,
         payload: {
           songId: 123,
           playing: false,
@@ -136,6 +139,7 @@ describe(globalEffects.name, () => {
 
         expect(result).toStrictEqual<ActionStateSetRemote>({
           type: ActionTypeRemote.StateSet,
+          priority: 0,
           payload: {
             songId: 123,
             playing: true,
@@ -169,6 +173,7 @@ describe(globalEffects.name, () => {
 
         expect(result).toStrictEqual<ActionStateSetRemote>({
           type: ActionTypeRemote.StateSet,
+          priority: 0,
           payload: expect.objectContaining({
             activeClients: ['other-client'],
           }),
@@ -191,6 +196,7 @@ describe(globalEffects.name, () => {
 
         expect(result).toStrictEqual<ActionStateSetRemote>({
           type: ActionTypeRemote.StateSet,
+          priority: 0,
           payload: expect.objectContaining({
             activeClients: expect.arrayContaining(['some-client', 'other-client']),
           }),
@@ -228,6 +234,7 @@ describe(globalEffects.name, () => {
 
         expect(result).toStrictEqual<ActionStateSetRemote>({
           type: ActionTypeRemote.StateSet,
+          priority: 0,
           payload: {
             songId: 123,
             playing: false,
@@ -280,6 +287,7 @@ describe(globalEffects.name, () => {
 
         expect(result).toStrictEqual<ActionStateSetRemote>({
           type: ActionTypeRemote.StateSet,
+          priority: 0,
           payload: {
             songId: 185,
             playing: true,
@@ -319,6 +327,7 @@ describe(globalEffects.name, () => {
       );
       expect(result).toStrictEqual<ActionStateSetRemote>({
         type: ActionTypeRemote.StateSet,
+        priority: 0,
         payload: {
           ...initialState.player,
           master: 'some-master',
@@ -354,6 +363,7 @@ describe(globalEffects.name, () => {
       const result = globalEffects(stateWithQueue, action);
       expect(result).toStrictEqual<ActionStateSetRemote>({
         type: ActionTypeRemote.StateSet,
+        priority: 0,
         payload: {
           ...initialState.player,
           master: 'some-master',
@@ -382,6 +392,7 @@ describe(globalEffects.name, () => {
 
       expect(result).toStrictEqual<ActionStateSetRemote>({
         type: ActionTypeRemote.StateSet,
+        priority: 0,
         payload: {
           ...initialState.player,
           master: 'some-master',
@@ -410,6 +421,7 @@ describe(globalEffects.name, () => {
 
       expect(result).toStrictEqual<ActionStateSetRemote>({
         type: ActionTypeRemote.StateSet,
+        priority: 0,
         payload: {
           ...initialState.player,
           master: 'some-master',

+ 11 - 0
gmus-web/src/effects/effects.ts

@@ -33,6 +33,7 @@ function pushToQueue(state: GlobalState, action: ActionQueuePushed): RemoteActio
   }
   return {
     type: ActionTypeRemote.StateSet,
+    priority: 0,
     payload: {
       ...state.player,
       queue: nextQueue,
@@ -50,6 +51,7 @@ function sendStateUpdateToServer(
   }
   return {
     type: ActionTypeRemote.StateSet,
+    priority: action.payload.priority ?? 0,
     payload: nextPlayer,
   };
 }
@@ -65,6 +67,7 @@ export function globalEffects(state: GlobalState, action: LocalAction): RemoteAc
       }
       return {
         type: ActionTypeRemote.StateSet,
+        priority: 0,
         payload: { ...state.player, seekTime: action.payload },
       };
 
@@ -72,6 +75,7 @@ export function globalEffects(state: GlobalState, action: LocalAction): RemoteAc
       if (action.payload) {
         return {
           type: ActionTypeRemote.StateSet,
+          priority: 0,
           payload: {
             ...state.player,
             seekTime: state.player.currentTime,
@@ -82,6 +86,7 @@ export function globalEffects(state: GlobalState, action: LocalAction): RemoteAc
 
       return {
         type: ActionTypeRemote.StateSet,
+        priority: 0,
         payload: {
           ...state.player,
           playing: false,
@@ -93,6 +98,7 @@ export function globalEffects(state: GlobalState, action: LocalAction): RemoteAc
     case ActionTypeLocal.ActiveClientToggled:
       return {
         type: ActionTypeRemote.StateSet,
+        priority: 0,
         payload: {
           ...state.player,
           activeClients: state.player.activeClients.includes(action.payload)
@@ -104,6 +110,7 @@ export function globalEffects(state: GlobalState, action: LocalAction): RemoteAc
     case ActionTypeLocal.PlayPaused:
       return {
         type: ActionTypeRemote.StateSet,
+        priority: 0,
         payload: {
           ...state.player,
           playing: !state.player.playing,
@@ -116,6 +123,7 @@ export function globalEffects(state: GlobalState, action: LocalAction): RemoteAc
       }
       return {
         type: ActionTypeRemote.StateSet,
+        priority: 0,
         payload: {
           ...state.player,
           songId: action.payload.song?.id ?? null,
@@ -133,6 +141,7 @@ export function globalEffects(state: GlobalState, action: LocalAction): RemoteAc
       }
       return {
         type: ActionTypeRemote.StateSet,
+        priority: 0,
         payload: {
           ...state.player,
           queue: state.player.queue.slice(1),
@@ -148,6 +157,7 @@ export function globalEffects(state: GlobalState, action: LocalAction): RemoteAc
       }
       return {
         type: ActionTypeRemote.StateSet,
+        priority: 0,
         payload: {
           ...state.player,
           queue: state.player.queue.filter((id) => id !== action.payload),
@@ -156,6 +166,7 @@ export function globalEffects(state: GlobalState, action: LocalAction): RemoteAc
     case ActionTypeLocal.QueueOrdered:
       return {
         type: ActionTypeRemote.StateSet,
+        priority: 0,
         payload: { ...state.player, queue: reorderQueue(state.player.queue, action) },
       };
 

+ 18 - 0
gmus-web/src/reducer/reducer.spec.ts

@@ -33,6 +33,7 @@ describe(globalReducer.name, () => {
         const actionFromOtherClient: ActionStateSetRemote = {
           type: ActionTypeRemote.StateSet,
           fromClient: 'other-client',
+          priority: 0,
           payload: {
             songId: 123,
             playing: true,
@@ -60,6 +61,23 @@ describe(globalReducer.name, () => {
             shuffleMode: false,
           });
         });
+
+        describe('when the priority was 1', () => {
+          const actionWithLowerPriority: ActionStateSetRemote = {
+            ...actionFromOtherClient,
+            priority: 1,
+          };
+
+          it('should only update the currentTime', () => {
+            expect.assertions(1);
+            const result = globalReducer(stateMaster, actionWithLowerPriority);
+
+            expect(result.player).toStrictEqual<MusicPlayer>({
+              ...stateMaster.player,
+              currentTime: 75,
+            });
+          });
+        });
       });
 
       describe('and the action came from ourselves', () => {

+ 13 - 1
gmus-web/src/reducer/reducer.ts

@@ -37,8 +37,20 @@ function shouldSetSeekTime(state: GlobalState, action: ActionStateSetRemote): bo
   return willBeMaster(state, action) && !(isMaster(state) && isFromOurselves(state, action));
 }
 
+function getNextRemotePlayer(
+  existingPlayer: MusicPlayer,
+  action: ActionStateSetRemote,
+): MusicPlayer {
+  if (!action.payload) {
+    return action.priority > 0 ? existingPlayer : nullPlayer;
+  }
+  return action.priority > 0
+    ? { ...existingPlayer, currentTime: action.payload.currentTime }
+    : action.payload;
+}
+
 function onRemoteStateSet(state: GlobalState, action: ActionStateSetRemote): GlobalState {
-  const nextPlayer = action.payload ?? nullPlayer;
+  const nextPlayer = getNextRemotePlayer(state.player, action);
   const seekTime = shouldSetSeekTime(state, action) ? nextPlayer.seekTime : -1;
 
   const nextPlayerWithSeekTime: MusicPlayer = { ...nextPlayer, seekTime };

+ 13 - 4
gmus-web/src/selectors.ts

@@ -4,11 +4,12 @@ import { MusicPlayer } from './types';
 
 export function getNextPlayerStateFromAction(
   player: MusicPlayer | undefined,
-  payload: ActionStateSetLocal['payload'] | null,
+  action: ActionStateSetLocal['payload'] | ActionStateSetRemote | null,
 ): MusicPlayer | null {
-  if (!(payload && player)) {
+  if (!(action && player)) {
     return null;
   }
+  const { payload } = action;
   if (typeof payload === 'function') {
     return { ...player, ...payload(player) };
   }
@@ -26,15 +27,23 @@ export const isFromOurselves = (
   action: ActionRemote,
 ): boolean => state.myClientName === action.fromClient;
 
+const isLocalStateSet = (
+  action: ActionStateSetLocal | ActionStateSetRemote,
+): action is ActionStateSetLocal => Reflect.has(action.payload ?? {}, 'priority');
+
 export const willBeMaster = (
   state: Partial<GlobalState> & Pick<GlobalState, 'myClientName'>,
-  action: ActionStateSetLocal | ActionStateSetRemote,
+  fullAction: ActionStateSetLocal | ActionStateSetRemote,
 ): boolean => {
+  const action: ActionStateSetLocal['payload'] | ActionStateSetRemote = isLocalStateSet(fullAction)
+    ? fullAction.payload
+    : fullAction;
+
   const actionHasMaster =
     typeof action.payload === 'function' ? !!action.payload({}).master : !!action.payload?.master;
   return (
     actionHasMaster &&
-    state.myClientName === getNextPlayerStateFromAction(state.player, action.payload)?.master
+    state.myClientName === getNextPlayerStateFromAction(state.player, action)?.master
   );
 };