Forráskód Böngészése

feat: play queue

    * feat: queue view
    * fix: don't issue queue actions if there is no master
    * chore: removed deprecated package
    * feat: new route to fetch multiple song info at once
    * chore: added queue to server action tests
    * feat: add to queue when pressing e
    * feat: state and actions to handle play queue
    * chore: put global reducer in StateInspector
    * feat: validate actions from the client prior to publishing
    * fix: dispatch to pubsub when skipping songs from slave
    * feat: require user interaction prior to loading the UI (so that sound can play)
    * fix: await call to audio.play()
Fela Maslen 5 éve
szülő
commit
1b15dfcbfe
48 módosított fájl, 1318 hozzáadás és 188 törlés
  1. 8 1
      gmus-backend/go.mod
  2. 31 7
      gmus-backend/go.sum
  3. 11 14
      gmus-backend/pkg/repository/songs.go
  4. 59 6
      gmus-backend/pkg/repository/songs_test.go
  5. 55 8
      gmus-backend/pkg/server/actions.go
  6. 248 0
      gmus-backend/pkg/server/actions_test.go
  7. 13 12
      gmus-backend/pkg/server/clients.go
  8. 63 14
      gmus-backend/pkg/server/fetch.go
  9. 3 3
      gmus-backend/pkg/server/handler.go
  10. 4 4
      gmus-backend/pkg/server/pubsub.go
  11. 2 1
      gmus-backend/pkg/server/server.go
  12. 13 0
      gmus-backend/pkg/server/server_suite_test.go
  13. 10 9
      gmus-backend/pkg/server/stream.go
  14. 9 6
      gmus-backend/pkg/server/types.go
  15. 1 2
      gmus-web/.eslintrc.js
  16. 0 1
      gmus-web/package.json
  17. 32 1
      gmus-web/src/actions/actions.ts
  18. 4 0
      gmus-web/src/actions/types.ts
  19. 17 31
      gmus-web/src/components/app.tsx
  20. 21 6
      gmus-web/src/components/identify.spec.tsx
  21. 4 2
      gmus-web/src/components/identify.tsx
  22. 25 0
      gmus-web/src/components/interact.tsx
  23. 6 2
      gmus-web/src/components/player.tsx
  24. 11 10
      gmus-web/src/components/root.tsx
  25. 9 0
      gmus-web/src/components/ui/cmus/actions.ts
  26. 19 0
      gmus-web/src/components/ui/cmus/reducer/fixtures.ts
  27. 90 2
      gmus-web/src/components/ui/cmus/reducer/keypress.spec.ts
  28. 37 10
      gmus-web/src/components/ui/cmus/reducer/keypress.ts
  29. 16 0
      gmus-web/src/components/ui/cmus/reducer/order.ts
  30. 12 1
      gmus-web/src/components/ui/cmus/reducer/reducer.spec.ts
  31. 15 0
      gmus-web/src/components/ui/cmus/reducer/reducer.ts
  32. 15 0
      gmus-web/src/components/ui/cmus/reducer/scroll.ts
  33. 5 0
      gmus-web/src/components/ui/cmus/types.ts
  34. 9 0
      gmus-web/src/components/ui/cmus/views/help.tsx
  35. 28 0
      gmus-web/src/components/ui/cmus/views/queue.styles.ts
  36. 70 0
      gmus-web/src/components/ui/cmus/views/queue.tsx
  37. 2 0
      gmus-web/src/components/ui/cmus/wrapper.tsx
  38. 198 5
      gmus-web/src/effects/effects.spec.ts
  39. 87 1
      gmus-web/src/effects/effects.ts
  40. 2 0
      gmus-web/src/hooks/master.spec.tsx
  41. 25 5
      gmus-web/src/hooks/queue.ts
  42. 1 1
      gmus-web/src/hooks/status.spec.tsx
  43. 4 0
      gmus-web/src/hooks/vim.ts
  44. 4 1
      gmus-web/src/index.tsx
  45. 18 3
      gmus-web/src/reducer/reducer.spec.ts
  46. 1 0
      gmus-web/src/reducer/reducer.ts
  47. 1 0
      gmus-web/src/types/state.ts
  48. 0 19
      gmus-web/yarn.lock

+ 8 - 1
gmus-backend/go.mod

@@ -3,17 +3,23 @@ module github.com/felamaslen/gmus-backend
 go 1.15
 
 require (
+	github.com/alicebob/miniredis v2.5.0+incompatible // indirect
+	github.com/alicebob/miniredis/v2 v2.14.1
 	github.com/anyhon/engine v0.1.0
 	github.com/dhowden/tag v0.0.0-20200828214007-46e57f75dbfc
-	github.com/go-redis/redis/v7 v7.4.0
+	github.com/elliotchance/redismock v1.5.3
+	github.com/go-playground/validator/v10 v10.4.1
+	github.com/go-redis/redis v6.15.9+incompatible
 	github.com/gofrs/uuid v3.2.0+incompatible // indirect
 	github.com/golang-migrate/migrate/v4 v4.13.0
+	github.com/gomodule/redigo v1.8.3 // indirect
 	github.com/gorilla/mux v1.8.0
 	github.com/gorilla/websocket v1.4.2
 	github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 // indirect
 	github.com/jackc/pgx v3.6.2+incompatible
 	github.com/jmoiron/sqlx v1.2.0
 	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
@@ -21,4 +27,5 @@ require (
 	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
+	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 )

+ 31 - 7
gmus-backend/go.sum

@@ -41,6 +41,12 @@ github.com/ClickHouse/clickhouse-go v1.3.12/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhH
 github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
 github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
 github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
+github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
+github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
+github.com/alicebob/miniredis v2.5.0+incompatible h1:yBHoLpsyjupjz3NL3MhKMVkR41j82Yjf3KFv7ApYzUI=
+github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk=
+github.com/alicebob/miniredis/v2 v2.14.1 h1:GjlbSeoJ24bzdLRs13HoMEeaRZx9kg5nHoRW7QV/nCs=
+github.com/alicebob/miniredis/v2 v2.14.1/go.mod h1:uS970Sw5Gs9/iK3yBg0l9Uj9s25wXxSpQUE9EaJ/Blg=
 github.com/anyhon/engine v0.1.0 h1:z8LToJ0A7O3om+Iz+FfFDTqzmBYiIMBhGyFNvySq5NI=
 github.com/anyhon/engine v0.1.0/go.mod h1:1sir2IWytku5IeTl8s92+MJygKx5Y5IQhEq/+PX8RC0=
 github.com/apache/arrow/go/arrow v0.0.0-20200601151325-b2287a20f230/go.mod h1:QNYViu/X0HXDHw7m3KXzWSVXIbfUvJqBFe6Gj8/pYA0=
@@ -85,6 +91,8 @@ github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD
 github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
 github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
 github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
+github.com/elliotchance/redismock v1.5.3 h1:Lgi2CLfVB3PamPI1SPqjJf5AiGisPFMWvIOCiRIq+sI=
+github.com/elliotchance/redismock v1.5.3/go.mod h1:8FFsGWghPUyP7nqj/UYXr2xqd6U2iNMxS4S5+Xadl5A=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -96,8 +104,16 @@ github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
-github.com/go-redis/redis/v7 v7.4.0 h1:7obg6wUoj05T0EpY0o8B59S9w5yeMWql7sw2kwNW1x4=
-github.com/go-redis/redis/v7 v7.4.0/go.mod h1:JDNMw23GTyLNC4GZu9njt15ctBQVn7xjRfnwdHj/Dcg=
+github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
+github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
+github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
+github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
+github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
+github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
+github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
+github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
+github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
 github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 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=
@@ -139,6 +155,8 @@ github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0
 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/snappy v0.0.0-20170215233205-553a64147049/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/gomodule/redigo v1.8.3 h1:HR0kYDX2RJZvAup8CsiJwxB4dTCSC0AaUq6S4SiLwUc=
+github.com/gomodule/redigo v1.8.3/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0=
 github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/flatbuffers v1.11.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
@@ -242,6 +260,9 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
 github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
+github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
+github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
 github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@@ -264,13 +285,11 @@ github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI
 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/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.10.1/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/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
 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=
@@ -308,6 +327,7 @@ github.com/snowflakedb/gosnowflake v1.3.5/go.mod h1:13Ky+lxzIm3VqNDZJdyvu9MCGy+W
 github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
 github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
 github.com/stretchr/testify v1.2.0/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
@@ -315,6 +335,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
 github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs=
 github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I=
@@ -322,6 +344,8 @@ 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/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=
 gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE=
 go.mongodb.org/mongo-driver v1.1.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=
@@ -394,7 +418,6 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
 golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -428,6 +451,7 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
 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=
+golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -443,7 +467,6 @@ golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -623,7 +646,6 @@ gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:a
 gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
-gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -631,6 +653,8 @@ gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
 gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E=
 gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
 honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

+ 11 - 14
gmus-backend/pkg/repository/songs.go

@@ -1,8 +1,6 @@
 package repository
 
 import (
-	"errors"
-
 	"github.com/felamaslen/gmus-backend/pkg/read"
 	"github.com/jmoiron/sqlx"
 	"github.com/lib/pq"
@@ -10,12 +8,17 @@ import (
 
 const BATCH_SIZE = 100
 
-func SelectSong(db *sqlx.DB, id int) (song *read.Song, err error) {
-	var songs []*read.Song
+func SelectSong(db *sqlx.DB, ids []int) (songs *[]*read.Song, err error) {
+	songs = &[]*read.Song{}
+	var idsArray pq.Int64Array
+	for _, id := range ids {
+		idsArray = append(idsArray, int64(id))
+	}
 
-	err = db.Select(&songs, `
+	err = db.Select(songs, `
   select
-    track_number
+    id
+    ,track_number
     ,title
     ,artist
     ,album
@@ -24,14 +27,8 @@ func SelectSong(db *sqlx.DB, id int) (song *read.Song, err error) {
     ,base_path
     ,relative_path
   from songs
-  where id = $1
-  `, int64(id))
-
-	if len(songs) == 0 {
-		err = errors.New("No such ID")
-	} else {
-		song = songs[0]
-	}
+  where id = ANY($1)
+  `, idsArray)
 
 	return
 }

+ 59 - 6
gmus-backend/pkg/repository/songs_test.go

@@ -19,6 +19,7 @@ var _ = Describe("songs repository", func() {
 
 	Describe("SelectSong", func() {
 		var id int64
+		var id2 int64
 
 		BeforeEach(func() {
 			db.QueryRowx(
@@ -36,16 +37,34 @@ var _ = Describe("songs repository", func() {
 				"/path/to",
 				"file.ogg",
 			).Scan(&id)
+
+			db.QueryRowx(
+				`
+	insert into songs (track_number, title, artist, album, duration, modified_date, base_path, relative_path)
+	values ($1, $2, $3, $4, $5, $6, $7, $8)
+	returning id
+	`,
+				13,
+				"Track 1",
+				"Untitled Artist",
+				"Some album",
+				218,
+				1993,
+				"/path/different",
+				"other.ogg",
+			).Scan(&id2)
 		})
 
 		It("should retrieve a song from the database", func() {
 			Expect(id).NotTo(BeZero())
 
-			result, err := repository.SelectSong(db, int(id))
+			result, err := repository.SelectSong(db, []int{int(id)})
 
 			Expect(err).To(BeNil())
 
-			Expect(result).To(Equal(&read.Song{
+			Expect(*result).To(HaveLen(1))
+			Expect((*result)[0]).To(Equal(&read.Song{
+				Id:           int(id),
 				TrackNumber:  7,
 				Title:        "Hey Jude",
 				Artist:       "The Beatles",
@@ -57,12 +76,46 @@ var _ = Describe("songs repository", func() {
 			}))
 		})
 
+		It("should retrieve multiple songs from the database", func() {
+			Expect(id).NotTo(BeZero())
+			Expect(id2).NotTo(BeZero())
+
+			result, err := repository.SelectSong(db, []int{int(id), int(id2)})
+
+			Expect(err).To(BeNil())
+
+			Expect(*result).To(HaveLen(2))
+			Expect((*result)[0]).To(Equal(&read.Song{
+				Id:           int(id),
+				TrackNumber:  7,
+				Title:        "Hey Jude",
+				Artist:       "The Beatles",
+				Album:        "",
+				Duration:     431,
+				BasePath:     "/path/to",
+				RelativePath: "file.ogg",
+				ModifiedDate: 8876,
+			}))
+
+			Expect((*result)[1]).To(Equal(&read.Song{
+				Id:           int(id2),
+				TrackNumber:  13,
+				Title:        "Track 1",
+				Artist:       "Untitled Artist",
+				Album:        "Some album",
+				Duration:     218,
+				BasePath:     "/path/different",
+				RelativePath: "other.ogg",
+				ModifiedDate: 1993,
+			}))
+		})
+
 		Context("when the song does not exist", func() {
-			It("should return an error", func() {
-				result, err := repository.SelectSong(db, 88113)
+			It("should return an empty array", func() {
+				result, err := repository.SelectSong(db, []int{88113})
 
-				Expect(err).To(MatchError("No such ID"))
-				Expect(result).To(BeNil())
+				Expect(err).To(BeNil())
+				Expect(*result).To(HaveLen(0))
 			})
 		})
 	})

+ 55 - 8
gmus-backend/pkg/server/actions.go

@@ -2,16 +2,19 @@ package server
 
 import (
 	"encoding/json"
+	"errors"
+	"fmt"
 
 	"github.com/felamaslen/gmus-backend/pkg/logger"
-	"github.com/go-redis/redis/v7"
+	"github.com/go-playground/validator/v10"
+	"github.com/go-redis/redis"
 )
 
 type ActionType string
 
 const (
 	StateSet          ActionType = "STATE_SET"
-	ClientListUpdated            = "CLIENT_LIST_UPDATED"
+	ClientListUpdated ActionType = "CLIENT_LIST_UPDATED"
 )
 
 type Action struct {
@@ -20,7 +23,7 @@ type Action struct {
 	Payload    interface{} `json:"payload"`
 }
 
-func broadcastAction(l *logger.Logger, thisPodClients *map[string]*Client, action *Action) []error {
+func BroadcastAction(l *logger.Logger, thisPodClients *map[string]*Client, action *Action) []error {
 	var errors []error
 
 	for _, client := range *thisPodClients {
@@ -33,13 +36,57 @@ func broadcastAction(l *logger.Logger, thisPodClients *map[string]*Client, actio
 	return errors
 }
 
-func publishAction(rdb *redis.Client, action *Action) error {
-	pubsubPayload, err := json.Marshal(action)
-	if err != nil {
-		return err
+func validateAction(action *Action) (validatedAction *Action, err error) {
+	switch action.Type {
+	case StateSet:
+		var remarshaledPayload []byte
+		remarshaledPayload, err = json.Marshal(action.Payload)
+		if err != nil {
+			return
+		}
+
+		var playerState MusicPlayer
+		err = json.Unmarshal(remarshaledPayload, &playerState)
+		if err != nil {
+			return
+		}
+
+		v := validator.New()
+		err = v.Struct(playerState)
+		if err != nil {
+			err = errors.New(err.Error())
+			return
+		}
+
+		validatedAction = &Action{
+			Type:       StateSet,
+			FromClient: action.FromClient,
+			Payload:    playerState,
+		}
+		return
+	default:
+		err = errors.New(fmt.Sprintf("Invalid client action type: %s", action.Type))
+		return
 	}
-	if _, err := rdb.Publish(TOPIC_BROADCAST, pubsubPayload).Result(); err != nil {
+}
+
+func PublishAction(rdb redis.Cmdable, action []byte) error {
+	if _, err := rdb.Publish(TOPIC_BROADCAST, action).Result(); err != nil {
 		return err
 	}
 	return nil
 }
+
+func PublishActionFromClient(rdb redis.Cmdable, action *Action) error {
+	validatedAction, validationErr := validateAction(action)
+	if validationErr != nil {
+		return validationErr
+	}
+
+	pubsubPayload, err := json.Marshal(validatedAction)
+	if err != nil {
+		return err
+	}
+
+	return PublishAction(rdb, pubsubPayload)
+}

+ 248 - 0
gmus-backend/pkg/server/actions_test.go

@@ -0,0 +1,248 @@
+package server_test
+
+import (
+	"encoding/json"
+
+	"github.com/felamaslen/gmus-backend/pkg/server"
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
+	"github.com/alicebob/miniredis/v2"
+	"github.com/elliotchance/redismock"
+	"github.com/go-redis/redis"
+)
+
+func newTestRedis() *redismock.ClientMock {
+	mr, err := miniredis.Run()
+	if err != nil {
+		panic(err)
+	}
+
+	client := redis.NewClient(&redis.Options{
+		Addr: mr.Addr(),
+	})
+
+	return redismock.NewNiceMock(client)
+}
+
+var _ = Describe("Server actions", func() {
+	var rdb *redismock.ClientMock
+	BeforeEach(func() {
+		rdb = newTestRedis()
+		rdb.On("Publish").Return(redis.NewIntResult(0, nil))
+	})
+
+	Describe("PublishActionFromClient", func() {
+		Describe("state set actions", func() {
+			Context("when the action is valid", func() {
+				var action server.Action
+				json.Unmarshal([]byte(`
+{
+  "type": "STATE_SET",
+  "payload": {
+    "songId": 123,
+    "playing": true,
+    "currentTime": 94,
+    "seekTime": -1,
+    "queue": [],
+    "master": "some-master-client"
+  }
+}
+`), &action)
+				myClient := "my-client"
+				action.FromClient = &myClient
+
+				songId := 123
+				expectedAction := server.Action{
+					Type:       server.StateSet,
+					FromClient: &myClient,
+					Payload: server.MusicPlayer{
+						SongId:      &songId,
+						Playing:     true,
+						CurrentTime: 94,
+						SeekTime:    -1,
+						Master:      "some-master-client",
+						Queue:       &[]int{},
+					},
+				}
+				expectedActionString, jsonErr := json.Marshal(expectedAction)
+				if jsonErr != nil {
+					panic(jsonErr)
+				}
+
+				BeforeEach(func() {
+					rdb.On("Publish", server.TOPIC_BROADCAST, expectedActionString).Return(redis.NewIntResult(0, nil))
+				})
+
+				It("should publish the action to the redis pubsub", func() {
+					err := server.PublishActionFromClient(rdb, &action)
+					Expect(rdb.Calls).NotTo(BeEmpty())
+					Expect(err).To(BeNil())
+				})
+			})
+
+			Context("when the song ID is non-positive", func() {
+				var action server.Action
+				json.Unmarshal([]byte(`
+{
+  "type": "STATE_SET",
+  "payload": {
+    "songId": 0,
+    "playing": true,
+    "currentTime": 94,
+    "seekTime": -1,
+    "queue": [],
+    "master": "some-master-client"
+  }
+}
+`), &action)
+
+				It("should not publish a message", func() {
+					err := server.PublishActionFromClient(rdb, &action)
+					Expect(rdb.Calls).To(BeEmpty())
+					Expect(err).NotTo(BeNil())
+				})
+			})
+
+			Context("when the song ID is null", func() {
+				var action server.Action
+				json.Unmarshal([]byte(`
+{
+  "type": "STATE_SET",
+  "payload": {
+    "songId": null,
+    "playing": false,
+    "currentTime": 0,
+    "seekTime": -1,
+    "queue": [],
+    "master": "some-master-client"
+  }
+}
+`), &action)
+
+				expectedAction := server.Action{
+					Type: server.StateSet,
+					Payload: server.MusicPlayer{
+						SongId:      nil,
+						Playing:     false,
+						CurrentTime: 0,
+						SeekTime:    -1,
+						Master:      "some-master-client",
+						Queue:       &[]int{},
+					},
+				}
+
+				expectedActionString, jsonErr := json.Marshal(expectedAction)
+				if jsonErr != nil {
+					panic(jsonErr)
+				}
+
+				BeforeEach(func() {
+					rdb.On("Publish", server.TOPIC_BROADCAST, expectedActionString).Return(redis.NewIntResult(0, nil))
+				})
+
+				It("should publish a message", func() {
+					err := server.PublishActionFromClient(rdb, &action)
+					Expect(rdb.Calls).NotTo(BeEmpty())
+					Expect(err).To(BeNil())
+				})
+			})
+
+			Context("when the current time is negative", func() {
+				var action server.Action
+				json.Unmarshal([]byte(`
+{
+  "type": "STATE_SET",
+  "payload": {
+    "songId": 123,
+    "playing": false,
+    "currentTime": -32,
+    "seekTime": -1,
+    "queue": [],
+    "master": "some-master-client"
+  }
+}
+`), &action)
+
+				It("should not publish a message", func() {
+					err := server.PublishActionFromClient(rdb, &action)
+					Expect(rdb.Calls).To(BeEmpty())
+					Expect(err).NotTo(BeNil())
+				})
+			})
+
+			Context("when the seek time is less than -1", func() {
+				var action server.Action
+				json.Unmarshal([]byte(`
+{
+  "type": "STATE_SET",
+  "payload": {
+    "songId": 123,
+    "playing": false,
+    "currentTime": 13,
+    "seekTime": -3,
+    "queue": [],
+    "master": "some-master-client"
+  }
+}
+`), &action)
+
+				It("should not publish a message", func() {
+					err := server.PublishActionFromClient(rdb, &action)
+					Expect(rdb.Calls).To(BeEmpty())
+					Expect(err).NotTo(BeNil())
+				})
+			})
+
+			Context("when the master is empty", func() {
+				var action server.Action
+				json.Unmarshal([]byte(`
+{
+  "type": "STATE_SET",
+  "payload": {
+    "songId": 123,
+    "playing": false,
+    "currentTime": 13,
+    "seekTime": -3,
+    "queue": [],
+    "master": ""
+  }
+}
+`), &action)
+
+				It("should not publish a message", func() {
+					err := server.PublishActionFromClient(rdb, &action)
+					Expect(rdb.Calls).To(BeEmpty())
+					Expect(err).NotTo(BeNil())
+				})
+			})
+		})
+
+		Describe("when the action is unrecognised", func() {
+			var action server.Action
+			// CLIENT_LIST_UPDATED should only ever come from the server
+			err := json.Unmarshal([]byte(`
+{
+  "type": "CLIENT_LIST_UPDATED",
+  "payload": [
+    { "name": "client-a", "lastPing": 123 },
+    { "name": "client-b", "lastPing": 456 }
+  ]
+}
+`), &action)
+			if err != nil {
+				panic(err)
+			}
+
+			BeforeEach(func() {
+				rdb.On("Publish", server.TOPIC_BROADCAST, "").Return(redis.NewIntResult(0, nil))
+			})
+
+			It("should not publish a message", func() {
+				err := server.PublishActionFromClient(rdb, &action)
+				Expect(rdb.Calls).To(BeEmpty())
+				Expect(err).NotTo(BeNil())
+			})
+		})
+	})
+})

+ 13 - 12
gmus-backend/pkg/server/clients.go

@@ -1,11 +1,12 @@
 package server
 
 import (
+	"encoding/json"
 	"net/http"
 	"time"
 
 	"github.com/felamaslen/gmus-backend/pkg/logger"
-	"github.com/go-redis/redis/v7"
+	"github.com/go-redis/redis"
 )
 
 func getClientNameFromRequest(r *http.Request) string {
@@ -22,7 +23,7 @@ func endSubscription(sub *redis.PubSub) error {
 	return nil
 }
 
-func publishClientList(l *logger.Logger, rdb *redis.Client) error {
+func publishClientList(l *logger.Logger, rdb redis.Cmdable) error {
 	clients, err := rdb.ZRangeWithScores(KEY_CLIENT_NAMES, 0, -1).Result()
 	if err != nil {
 		return err
@@ -36,12 +37,12 @@ func publishClientList(l *logger.Logger, rdb *redis.Client) error {
 		})
 	}
 
-	actionClientListUpdated := Action{
+	actionClientListUpdated, err := json.Marshal(Action{
 		Type:    ClientListUpdated,
 		Payload: members,
-	}
+	})
 
-	if err := publishAction(rdb, &actionClientListUpdated); err != nil {
+	if err := PublishAction(rdb, actionClientListUpdated); err != nil {
 		return err
 	}
 	return nil
@@ -53,11 +54,11 @@ func (c *Client) send(message interface{}) error {
 	return c.conn.WriteJSON(message)
 }
 
-func (c *Client) exposeToNetwork(l *logger.Logger, rdb *redis.Client) error {
+func (c *Client) exposeToNetwork(l *logger.Logger, rdb redis.Cmdable) error {
 	// Expose the client to all pods running the server
 	now := time.Now().Unix()
 
-	if _, err := rdb.ZAdd(KEY_CLIENT_NAMES, &redis.Z{
+	if _, err := rdb.ZAdd(KEY_CLIENT_NAMES, redis.Z{
 		Score:  float64(now),
 		Member: c.name,
 	}).Result(); err != nil {
@@ -69,7 +70,7 @@ func (c *Client) exposeToNetwork(l *logger.Logger, rdb *redis.Client) error {
 	return nil
 }
 
-func (c *Client) disposeFromNetwork(l *logger.Logger, rdb *redis.Client) error {
+func (c *Client) disposeFromNetwork(l *logger.Logger, rdb redis.Cmdable) error {
 	// Make sure other clients know when one goes away
 	if _, err := rdb.ZRem(KEY_CLIENT_NAMES, c.name).Result(); err != nil {
 		return err
@@ -80,7 +81,7 @@ func (c *Client) disposeFromNetwork(l *logger.Logger, rdb *redis.Client) error {
 	return nil
 }
 
-func (c *Client) subscribeToMe(l *logger.Logger, rdb *redis.Client) {
+func (c *Client) subscribeToMe(l *logger.Logger, rdb redis.Cmdable) {
 	// Subscribe this pod to messages from the client. This pod is responsible for
 	// onward publishing to other pods where necessary, via internal pubsub
 
@@ -99,14 +100,14 @@ func (c *Client) subscribeToMe(l *logger.Logger, rdb *redis.Client) {
 		} else {
 			actionFromClient.FromClient = &c.name
 
-			if err := publishAction(rdb, &actionFromClient); err != nil {
+			if err := PublishActionFromClient(rdb, &actionFromClient); err != nil {
 				l.Error("Error publishing action from client: %v\n", err)
 			}
 		}
 	}
 }
 
-func (c *Client) onConnect(l *logger.Logger, rdb *redis.Client) error {
+func (c *Client) onConnect(l *logger.Logger, rdb redis.Cmdable) error {
 	l.Verbose("[Client connected] %s\n", c.name)
 
 	if err := c.exposeToNetwork(l, rdb); err != nil {
@@ -119,7 +120,7 @@ func (c *Client) onConnect(l *logger.Logger, rdb *redis.Client) error {
 	return nil
 }
 
-func (c *Client) onDisconnect(l *logger.Logger, rdb *redis.Client) error {
+func (c *Client) onDisconnect(l *logger.Logger, rdb redis.Cmdable) error {
 	l.Verbose("[Client disconnected] %s\n", c.name)
 
 	if err := c.disposeFromNetwork(l, rdb); err != nil {

+ 63 - 14
gmus-backend/pkg/server/fetch.go

@@ -9,7 +9,7 @@ import (
 	"github.com/felamaslen/gmus-backend/pkg/logger"
 	"github.com/felamaslen/gmus-backend/pkg/read"
 	"github.com/felamaslen/gmus-backend/pkg/repository"
-	"github.com/go-redis/redis/v7"
+	"github.com/go-redis/redis"
 	"github.com/jmoiron/sqlx"
 )
 
@@ -17,7 +17,7 @@ type ArtistsResponse struct {
 	Artists []string `json:"artists"`
 }
 
-func routeFetchArtists(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
+func routeFetchArtists(l *logger.Logger, rdb redis.Cmdable, w http.ResponseWriter, r *http.Request) error {
 	db := database.GetConnection()
 	artists, err := repository.SelectAllArtists(db)
 	if err != nil {
@@ -40,7 +40,7 @@ type AlbumsResponse struct {
 	Albums []string `json:"albums"`
 }
 
-func routeFetchAlbums(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
+func routeFetchAlbums(l *logger.Logger, rdb redis.Cmdable, w http.ResponseWriter, r *http.Request) error {
 	artist := r.URL.Query().Get("artist")
 
 	db := database.GetConnection()
@@ -67,7 +67,7 @@ type SongsResponse struct {
 	Songs  *[]*read.SongExternal `json:"songs"`
 }
 
-func routeFetchSongs(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
+func routeFetchSongs(l *logger.Logger, rdb redis.Cmdable, w http.ResponseWriter, r *http.Request) error {
 	artist := r.URL.Query().Get("artist")
 
 	db := database.GetConnection()
@@ -100,7 +100,7 @@ func validateSongId(w http.ResponseWriter, r *http.Request) (id int, err error)
 	return
 }
 
-func routeFetchSongInfo(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
+func routeFetchSongInfo(l *logger.Logger, rdb redis.Cmdable, w http.ResponseWriter, r *http.Request) error {
 	id, err := validateSongId(w, r)
 	if err != nil {
 		return nil
@@ -108,14 +108,16 @@ func routeFetchSongInfo(l *logger.Logger, rdb *redis.Client, w http.ResponseWrit
 
 	db := database.GetConnection()
 
-	song, err := repository.SelectSong(db, id)
+	songs, err := repository.SelectSong(db, []int{id})
 	if err != nil {
-		if err.Error() == "No such ID" {
-			http.Error(w, "Song not found", http.StatusNotFound)
-			return nil
-		}
 		return err
 	}
+	if len(*songs) == 0 {
+		http.Error(w, "Song not found", http.StatusNotFound)
+		return nil
+	}
+
+	song := (*songs)[0]
 
 	response, err := json.Marshal(read.SongExternal{
 		Id:          id,
@@ -133,6 +135,53 @@ func routeFetchSongInfo(l *logger.Logger, rdb *redis.Client, w http.ResponseWrit
 	return nil
 }
 
+func routeFetchMultiSongInfo(l *logger.Logger, rdb redis.Cmdable, w http.ResponseWriter, r *http.Request) error {
+	idsArray := r.URL.Query()["ids"]
+	if len(idsArray) == 0 {
+		http.Error(w, "Must provide valid list of IDs", http.StatusBadRequest)
+		return nil
+	}
+
+	var ids []int
+	for _, id := range idsArray {
+		idInt, err := strconv.Atoi(id)
+		if err != nil {
+			http.Error(w, "All IDs must be numeric", http.StatusBadRequest)
+			return nil
+		}
+		if idInt < 1 {
+			http.Error(w, "All IDs must be positive integers", http.StatusBadRequest)
+			return nil
+		}
+		ids = append(ids, idInt)
+	}
+
+	songs, err := repository.SelectSong(database.GetConnection(), ids)
+	if err != nil {
+		return err
+	}
+
+	songsArray := []read.SongExternal{}
+	for _, song := range *songs {
+		songsArray = append(songsArray, read.SongExternal{
+			Id:          song.Id,
+			TrackNumber: song.TrackNumber,
+			Title:       song.Title,
+			Artist:      song.Artist,
+			Album:       song.Album,
+			Duration:    song.Duration,
+		})
+	}
+
+	response, err := json.Marshal(songsArray)
+	if err != nil {
+		return err
+	}
+
+	w.Write(response)
+	return nil
+}
+
 type NullResponse struct {
 	Id int `json:"id"`
 }
@@ -161,14 +210,14 @@ func respondWithSongOrNull(db *sqlx.DB, w http.ResponseWriter, song *read.Song)
 	return nil
 }
 
-func routeFetchNextSong(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
+func routeFetchNextSong(l *logger.Logger, rdb redis.Cmdable, w http.ResponseWriter, r *http.Request) error {
 	id, err := validateSongId(w, r)
 	if err != nil {
 		return nil
 	}
 
 	db := database.GetConnection()
-	nextSong, err := repository.GetNextSong(db, int64(id))
+	nextSong, err := repository.GetNextSong(db, id)
 	if err != nil {
 		return err
 	}
@@ -179,14 +228,14 @@ func routeFetchNextSong(l *logger.Logger, rdb *redis.Client, w http.ResponseWrit
 	return nil
 }
 
-func routeFetchPrevSong(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
+func routeFetchPrevSong(l *logger.Logger, rdb redis.Cmdable, w http.ResponseWriter, r *http.Request) error {
 	id, err := validateSongId(w, r)
 	if err != nil {
 		return nil
 	}
 
 	db := database.GetConnection()
-	prevSong, err := repository.GetPrevSong(db, int64(id))
+	prevSong, err := repository.GetPrevSong(db, id)
 	if err != nil {
 		return err
 	}

+ 3 - 3
gmus-backend/pkg/server/handler.go

@@ -4,14 +4,14 @@ import (
 	"net/http"
 
 	"github.com/felamaslen/gmus-backend/pkg/logger"
-	"github.com/go-redis/redis/v7"
+	"github.com/go-redis/redis"
 )
 
-type RouteHandler func(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error
+type RouteHandler func(l *logger.Logger, rdb redis.Cmdable, w http.ResponseWriter, r *http.Request) error
 
 func routeHandler(
 	l *logger.Logger,
-	rdb *redis.Client,
+	rdb redis.Cmdable,
 	handler RouteHandler,
 ) func(w http.ResponseWriter, r *http.Request) {
 

+ 4 - 4
gmus-backend/pkg/server/pubsub.go

@@ -7,7 +7,7 @@ import (
 	"time"
 
 	"github.com/felamaslen/gmus-backend/pkg/logger"
-	"github.com/go-redis/redis/v7"
+	"github.com/go-redis/redis"
 	"github.com/gorilla/mux"
 	"github.com/gorilla/websocket"
 )
@@ -19,7 +19,7 @@ var upgrader = websocket.Upgrader{
 }
 
 func handleClientSubscription(thisPodClients *map[string]*Client) RouteHandler {
-	return func(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
+	return func(l *logger.Logger, rdb redis.Cmdable, w http.ResponseWriter, r *http.Request) error {
 		clientName := getClientNameFromRequest(r)
 		if len(clientName) == 0 {
 			w.WriteHeader(400)
@@ -94,7 +94,7 @@ func subscribeToBroadcast(
 					l.Debug("[<-Client] %s (%s)\n", actionFromPubsub.Type, *actionFromPubsub.FromClient)
 				}
 
-				errors := broadcastAction(l, thisPodClients, &actionFromPubsub)
+				errors := BroadcastAction(l, thisPodClients, &actionFromPubsub)
 
 				if len(errors) > 0 {
 					l.Warn("Error broadcasting: %v\n", errors)
@@ -104,7 +104,7 @@ func subscribeToBroadcast(
 	}
 }
 
-func pruneDisappearedClients(l *logger.Logger, rdb *redis.Client) {
+func pruneDisappearedClients(l *logger.Logger, rdb redis.Cmdable) {
 	for {
 		now := time.Now().Unix()
 		rdb.ZRemRangeByScore(KEY_CLIENT_NAMES, "0", fmt.Sprintf("%d", now-CLIENT_TTL_SEC))

+ 2 - 1
gmus-backend/pkg/server/server.go

@@ -7,7 +7,7 @@ import (
 
 	"github.com/felamaslen/gmus-backend/pkg/config"
 	"github.com/felamaslen/gmus-backend/pkg/logger"
-	"github.com/go-redis/redis/v7"
+	"github.com/go-redis/redis"
 	"github.com/gorilla/mux"
 	"github.com/rs/cors"
 )
@@ -30,6 +30,7 @@ func StartServer() {
 	router.Path("/songs").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchSongs))
 
 	router.Path("/song-info").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchSongInfo))
+	router.Path("/multi-song-info").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchMultiSongInfo))
 
 	router.Path("/next-song").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchNextSong))
 	router.Path("/prev-song").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchPrevSong))

+ 13 - 0
gmus-backend/pkg/server/server_suite_test.go

@@ -0,0 +1,13 @@
+package server_test
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestRepository(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Server Suite")
+}

+ 10 - 9
gmus-backend/pkg/server/stream.go

@@ -10,10 +10,10 @@ import (
 	"github.com/felamaslen/gmus-backend/pkg/database"
 	"github.com/felamaslen/gmus-backend/pkg/logger"
 	"github.com/felamaslen/gmus-backend/pkg/repository"
-	"github.com/go-redis/redis/v7"
+	"github.com/go-redis/redis"
 )
 
-func streamSong(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
+func streamSong(l *logger.Logger, rdb redis.Cmdable, w http.ResponseWriter, r *http.Request) error {
 	songIdQuery := r.URL.Query().Get("songid")
 	songId, err := strconv.Atoi(songIdQuery)
 	if err != nil {
@@ -24,16 +24,17 @@ func streamSong(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *h
 
 	db := database.GetConnection()
 
-	song, err := repository.SelectSong(db, songId)
+	songs, err := repository.SelectSong(db, []int{songId})
 	if err != nil {
-		if err.Error() == "No such ID" {
-			w.WriteHeader(404)
-			w.Write([]byte("No such song"))
-			return nil
-		}
-
 		return err
 	}
+	if len(*songs) == 0 {
+		w.WriteHeader(404)
+		w.Write([]byte("No such song"))
+		return nil
+	}
+
+	song := (*songs)[0]
 
 	fullFilePath := fmt.Sprintf("%s/%s", song.BasePath, song.RelativePath)
 

+ 9 - 6
gmus-backend/pkg/server/state.go → gmus-backend/pkg/server/types.go

@@ -29,12 +29,15 @@ type Member struct {
 // The master client is responsible for:
 // 1. Playing the music
 // 2. Keeping the server updated regularly about the current state
+//
+// This type here is merely used for validation of client state messages.
+// Each client implementation MUST adhere to this spec.
 
 type MusicPlayer struct {
-	SongId      int     `json:"songId"`
-	Playing     bool    `json:"playing"`
-	CurrentTime float32 `json:"currentTime"`
-	SeekTime    int     `json:"setTime"`
-
-	Master string `json:"master"`
+	SongId      *int    `json:"songId" validate:"omitempty,gte=1"`
+	Playing     bool    `json:"playing" validate:"-"`
+	CurrentTime float32 `json:"currentTime" validate:"gte=0"`
+	SeekTime    float32 `json:"seekTime" validate:"min=-1"`
+	Master      string  `json:"master" validate:"required"`
+	Queue       *[]int  `json:"queue" validate:"required"`
 }

+ 1 - 2
gmus-web/.eslintrc.js

@@ -1,5 +1,5 @@
 module.exports = {
-  plugins: ['jsx-a11y', 'prettier', 'react', 'react-hooks', 'import', 'jest'],
+  plugins: ['jsx-a11y', 'react', 'react-hooks', 'import', 'jest'],
   parser: '@typescript-eslint/parser',
   parserOptions: {
     ecmaVersion: 8,
@@ -96,7 +96,6 @@ module.exports = {
     'no-unused-expressions': 'off',
     'no-use-before-define': 'off',
     'no-warning-comments': 'warn',
-    'prettier/prettier': ['error'],
     'react/jsx-filename-extension': [
       2,
       {

+ 0 - 1
gmus-web/package.json

@@ -70,7 +70,6 @@
     "axios": "^0.21.0",
     "eslint-config-airbnb-base": "^14.2.1",
     "eslint-config-prettier": "^7.0.0",
-    "eslint-plugin-prettier": "^3.3.0",
     "husky": "^4.3.6",
     "jest-websocket-mock": "^2.2.0",
     "lint-staged": "^10.5.3",

+ 32 - 1
gmus-web/src/actions/actions.ts

@@ -62,6 +62,33 @@ export const songInfoFetched = (song: Song | null, replace = false): ActionSongI
   payload: { song, replace },
 });
 
+export type ActionQueuePushed = ActionLocal<ActionTypeLocal.QueuePushed, number>;
+export const queuePushed = (songId: number): ActionQueuePushed => ({
+  type: ActionTypeLocal.QueuePushed,
+  payload: songId,
+});
+
+export type ActionQueueShifted = ActionLocal<ActionTypeLocal.QueueShifted, void>;
+export const queueShifted = (): ActionQueueShifted => ({
+  type: ActionTypeLocal.QueueShifted,
+  payload: undefined,
+});
+
+export type ActionQueueRemoved = ActionLocal<ActionTypeLocal.QueueRemoved, number>;
+export const queueRemoved = (songId: number): ActionQueueRemoved => ({
+  type: ActionTypeLocal.QueueRemoved,
+  payload: songId,
+});
+
+export type ActionQueueOrdered = ActionLocal<
+  ActionTypeLocal.QueueOrdered,
+  { songId: number; delta: -1 | 1 }
+>;
+export const queueOrdered = (songId: number, delta: -1 | 1): ActionQueueOrdered => ({
+  type: ActionTypeLocal.QueueOrdered,
+  payload: { songId, delta },
+});
+
 export type LocalAction =
   | LoggedOut
   | ActionErrorOccurred
@@ -70,6 +97,10 @@ export type LocalAction =
   | ActionSeeked
   | ActionPlayPaused
   | ActionMasterSet
-  | ActionSongInfoFetched;
+  | ActionSongInfoFetched
+  | ActionQueuePushed
+  | ActionQueueShifted
+  | ActionQueueRemoved
+  | ActionQueueOrdered;
 
 export type AnyAction = LocalAction | RemoteAction;

+ 4 - 0
gmus-web/src/actions/types.ts

@@ -14,6 +14,10 @@ export enum ActionTypeLocal {
   MasterSet = '@@local/MASTER_SET',
   PlayPaused = '@@local/PLAY_PAUSED',
   SongInfoFetched = '@@local/SONG_INFO_FETCHED',
+  QueuePushed = '@@local/QUEUE_PUSHED',
+  QueueShifted = '@@local/QUEUE_SHIFTED',
+  QueueRemoved = '@@local/QUEUE_REMOVED',
+  QueueOrdered = '@@local/QUEUE_ORDERED',
 }
 
 export interface Action<T extends string = string, P = unknown> {

+ 17 - 31
gmus-web/src/components/app.tsx

@@ -10,18 +10,20 @@ import { useCurrentlyPlayingSongInfo } from '../hooks/status';
 import { isMaster } from '../selectors';
 import { getSongUrl } from '../utils/url';
 import { LoadingWrapper } from './identify';
+import { Interact, Props as InteractProps } from './interact';
 import { Player } from './player';
 import { uiProviders } from './ui';
 import { UIProvider } from './ui/types';
 
 export type Props = {
   socket: WebSocket;
-};
+  interacted: boolean;
+} & InteractProps;
 
 const uiProvider = UIProvider.Cmus;
 const UI = uiProviders[uiProvider];
 
-export const App: React.FC<Props> = ({ socket }) => {
+export const App: React.FC<Props> = ({ socket, interacted, setInteracted }) => {
   useKeepalive(socket);
   useMaster();
   useCurrentlyPlayingSongInfo();
@@ -36,26 +38,7 @@ export const App: React.FC<Props> = ({ socket }) => {
     [dispatch],
   );
 
-  const { onNext, onPrev, loading: loadingQueue } = usePlayQueue();
-  const onEnded = useCallback(() => {
-    if (state.player.songId) {
-      onNext(state.player.songId);
-    }
-  }, [onNext, state.player.songId]);
-
-  const nextSong = useCallback(() => {
-    if (loadingQueue || !state.player.songId) {
-      return;
-    }
-    onNext(state.player.songId);
-  }, [loadingQueue, onNext, state.player.songId]);
-
-  const prevSong = useCallback(() => {
-    if (loadingQueue || !state.player.songId) {
-      return;
-    }
-    onPrev(state.player.songId);
-  }, [loadingQueue, onPrev, state.player.songId]);
+  const { onNext, onPrev } = usePlayQueue();
 
   return (
     <>
@@ -66,18 +49,21 @@ export const App: React.FC<Props> = ({ socket }) => {
           seekTime={state.player.seekTime}
           onTimeUpdate={onTimeUpdate}
           timeUpdateFPS={1}
-          onEnded={onEnded}
+          onEnded={onNext}
         />
       )}
       <StateInspector name="ui">
-        <Suspense fallback={<LoadingWrapper />}>
-          <UI
-            isMaster={isMaster(state)}
-            currentSong={state.songInfo}
-            nextSong={nextSong}
-            prevSong={prevSong}
-          />
-        </Suspense>
+        {!interacted && <Interact setInteracted={setInteracted} />}
+        {interacted && (
+          <Suspense fallback={<LoadingWrapper />}>
+            <UI
+              isMaster={isMaster(state)}
+              currentSong={state.songInfo}
+              nextSong={onNext}
+              prevSong={onPrev}
+            />
+          </Suspense>
+        )}
       </StateInspector>
     </>
   );

+ 21 - 6
gmus-web/src/components/identify.spec.tsx

@@ -1,4 +1,4 @@
-import { act, fireEvent, render } from '@testing-library/react';
+import { act, fireEvent, render, RenderResult } from '@testing-library/react';
 import React from 'react';
 
 import { Identify, Props } from './identify';
@@ -7,6 +7,7 @@ describe(Identify.name, () => {
   const props: Props = {
     connecting: false,
     onIdentify: jest.fn(),
+    setInteracted: jest.fn(),
   };
 
   it('should render an input', () => {
@@ -22,11 +23,10 @@ describe(Identify.name, () => {
   });
 
   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');
+    const setupConnect = (): RenderResult => {
+      const renderResult = render(<Identify {...props} />);
+      const input = renderResult.getByDisplayValue('');
+      const button = renderResult.getByText('Connect');
 
       act(() => {
         fireEvent.change(input, { target: { value: 'my-computer' } });
@@ -35,9 +35,24 @@ describe(Identify.name, () => {
         fireEvent.click(button);
       });
 
+      return renderResult;
+    };
+
+    it('should call the onIdentify prop', () => {
+      expect.assertions(2);
+      setupConnect();
+
       expect(props.onIdentify).toHaveBeenCalledTimes(1);
       expect(props.onIdentify).toHaveBeenCalledWith('my-computer');
     });
+
+    it('should set interacted to true', () => {
+      expect.assertions(2);
+      setupConnect();
+
+      expect(props.setInteracted).toHaveBeenCalledTimes(1);
+      expect(props.setInteracted).toHaveBeenCalledWith(true);
+    });
   });
 
   describe('when connecting', () => {

+ 4 - 2
gmus-web/src/components/identify.tsx

@@ -7,6 +7,7 @@ import * as Styled from './identify.styles';
 export type Props = {
   connecting: boolean;
   onIdentify: (name: string) => void;
+  setInteracted: (interacted: boolean) => void;
 };
 
 export const LoadingWrapper: React.FC = () => (
@@ -15,7 +16,7 @@ export const LoadingWrapper: React.FC = () => (
   </Styled.Container>
 );
 
-export const Identify: React.FC<Props> = ({ connecting, onIdentify }) => {
+export const Identify: React.FC<Props> = ({ connecting, onIdentify, setInteracted }) => {
   const [name, setName] = useState<string>('');
   const onChange = useCallback(
     (event: React.ChangeEvent<HTMLInputElement>) => setName(event.target.value),
@@ -23,7 +24,8 @@ export const Identify: React.FC<Props> = ({ connecting, onIdentify }) => {
   );
   const onConnect = useCallback(() => {
     onIdentify(name);
-  }, [name, onIdentify]);
+    setInteracted(true);
+  }, [name, onIdentify, setInteracted]);
 
   const input = useRef<HTMLInputElement>(null);
   useEffect(() => {

+ 25 - 0
gmus-web/src/components/interact.tsx

@@ -0,0 +1,25 @@
+import React, { useCallback, useEffect, useRef } from 'react';
+import { useCTA } from '../hooks/cta';
+import { Container } from './identify.styles';
+
+export type Props = {
+  setInteracted: (interacted: boolean) => void;
+};
+
+export const Interact: React.FC<Props> = ({ setInteracted }) => {
+  const onInteract = useCallback(() => setInteracted(true), [setInteracted]);
+  const ctaProps = useCTA(onInteract);
+
+  const button = useRef<HTMLButtonElement>(null);
+  useEffect(() => {
+    button.current?.focus();
+  }, []);
+
+  return (
+    <Container>
+      <button {...ctaProps} ref={button}>
+        Continue
+      </button>
+    </Container>
+  );
+};

+ 6 - 2
gmus-web/src/components/player.tsx

@@ -82,8 +82,12 @@ export const Player: React.FC<Props> = ({
     );
   }, []);
 
-  const play = useCallback(() => {
-    audio.current?.play();
+  const play = useCallback(async () => {
+    try {
+      await audio.current?.play();
+    } catch {
+      // pass
+    }
   }, []);
 
   const pause = useCallback(() => {

+ 11 - 10
gmus-web/src/components/root.tsx

@@ -1,19 +1,16 @@
-import React, { Reducer, useCallback, useReducer } from 'react';
+import React, { useCallback, useState } from 'react';
+import { useReducer } from 'reinspect';
 
-import { AnyAction, nameSet } from '../actions';
+import { nameSet } from '../actions';
 import { DispatchContext, StateContext } from '../context/state';
 import { useDispatchWithEffects, useOnMessage, useSocket } from '../hooks/socket';
-import { globalReducer, GlobalState, initialState } from '../reducer';
+import { globalReducer, initialState } from '../reducer';
 import { init } from '../utils/state';
 import { App } from './app';
 import { Identify } from './identify';
 
 export const Root: React.FC = () => {
-  const [state, dispatch] = useReducer<Reducer<GlobalState, AnyAction>, GlobalState>(
-    globalReducer,
-    initialState,
-    init,
-  );
+  const [state, dispatch] = useReducer(globalReducer, initialState, init, 'global');
 
   const onMessage = useOnMessage(dispatch);
 
@@ -28,14 +25,18 @@ export const Root: React.FC = () => {
 
   const dispatchWithEffects = useDispatchWithEffects(state, dispatch, socket);
 
+  const [interacted, setInteracted] = useState<boolean>(false);
+
   if (!(socket && connected && name) || error) {
-    return <Identify connecting={connecting} onIdentify={onIdentify} />;
+    return (
+      <Identify connecting={connecting} onIdentify={onIdentify} setInteracted={setInteracted} />
+    );
   }
 
   return (
     <StateContext.Provider value={state}>
       <DispatchContext.Provider value={dispatchWithEffects}>
-        <App socket={socket} />
+        <App socket={socket} interacted={interacted} setInteracted={setInteracted} />
       </DispatchContext.Provider>
     </StateContext.Provider>
   );

+ 9 - 0
gmus-web/src/components/ui/cmus/actions.ts

@@ -8,6 +8,7 @@ export enum CmusUIActionType {
   ArtistSongsLoaded = '@@ui/cmus/ARTIST_SONGS_LOADED',
   CommandSet = '@@ui/cmus/COMMAND_SET',
   ClientActivated = '@@ui/cmus/CLIENT_ACTIVATED',
+  QueueInfoLoaded = '@@ui/cmus/QUEUE_INFO_LOADED',
 }
 
 export type ArtistsSet = Action<CmusUIActionType.ArtistsSet, string[]>;
@@ -57,10 +58,18 @@ export const clientActivated = (name: string | null): ClientActivated => ({
   payload: name,
 });
 
+export type QueueInfoLoaded = Action<CmusUIActionType.QueueInfoLoaded, Song[]>;
+
+export const queueInfoLoaded = (songs: Song[]): QueueInfoLoaded => ({
+  type: CmusUIActionType.QueueInfoLoaded,
+  payload: songs,
+});
+
 export type CmusUIAction =
   | ArtistsSet
   | ArtistAlbumsLoaded
   | ArtistSongsLoaded
   | CommandSet
   | ClientActivated
+  | QueueInfoLoaded
   | ActionKeyPressed;

+ 19 - 0
gmus-web/src/components/ui/cmus/reducer/fixtures.ts

@@ -1,3 +1,4 @@
+import { Song } from '../../../../types';
 import { CmusUIState, LibraryModeWindow, View } from '../types';
 import { initialCmusUIState } from './reducer';
 
@@ -32,3 +33,21 @@ export const stateWithActiveArtist: CmusUIState = {
     activeArtist: 'My artist',
   },
 };
+
+export const stateWithActiveSong: CmusUIState = {
+  ...stateLibrary,
+  library: {
+    ...stateLibrary.library,
+    modeWindow: LibraryModeWindow.SongList,
+    activeSongId: 1867,
+  },
+};
+
+export const stateQueue: CmusUIState = {
+  ...initialCmusUIState,
+  view: View.Queue,
+  queue: {
+    info: [{ id: 887 } as Song, { id: 75 } as Song, { id: 189 } as Song],
+    active: null,
+  },
+};

+ 90 - 2
gmus-web/src/components/ui/cmus/reducer/keypress.spec.ts

@@ -1,10 +1,16 @@
-import { masterSet, playPaused, stateSet } from '../../../../actions';
+import { masterSet, playPaused, queueOrdered, queuePushed, stateSet } from '../../../../actions';
 import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../../hooks/vim';
 import { Song } from '../../../../types';
 
 import { CmusUIState, LibraryModeWindow, Overlay, View } from '../types';
 
-import { stateDifferentView, stateFromMode, stateLibrary } from './fixtures';
+import {
+  stateDifferentView,
+  stateFromMode,
+  stateLibrary,
+  stateQueue,
+  stateWithActiveSong,
+} from './fixtures';
 import { cmusUIReducer, initialCmusUIState } from './reducer';
 
 describe(ActionTypeKeyPressed, () => {
@@ -12,6 +18,7 @@ describe(ActionTypeKeyPressed, () => {
     key          | toView
     ${Keys['1']} | ${View.Library}
     ${Keys['2']} | ${View.ClientList}
+    ${Keys['3']} | ${View.Queue}
   `('$key', ({ key, toView }) => {
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key };
 
@@ -67,6 +74,18 @@ describe(ActionTypeKeyPressed, () => {
     });
   });
 
+  describe(Keys.E, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.E };
+
+    describe('when in library view', () => {
+      it('should set global action to add the selected song to the queue', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(stateWithActiveSong, action);
+        expect(result.globalAction).toStrictEqual(queuePushed(1867));
+      });
+    });
+  });
+
   describe(Keys.J, () => {
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.J };
 
@@ -174,6 +193,14 @@ describe(ActionTypeKeyPressed, () => {
       });
     });
 
+    describe('when in queue view', () => {
+      it('should select the next item in the queue', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(stateQueue, action);
+        expect(result.queue.active).toBe(887);
+      });
+    });
+
     describe('when in a different view', () => {
       it('should set the scroll delta and increment the serial number', () => {
         expect.assertions(1);
@@ -291,6 +318,17 @@ describe(ActionTypeKeyPressed, () => {
       });
     });
 
+    describe('when in queue view', () => {
+      it('should select the next item in the queue', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(
+          { ...stateQueue, queue: { ...stateQueue.queue, active: 189 } },
+          action,
+        );
+        expect(result.queue.active).toBe(75);
+      });
+    });
+
     describe('when in a different view', () => {
       it('should set the scroll delta and increment the serial number', () => {
         expect.assertions(1);
@@ -300,6 +338,36 @@ describe(ActionTypeKeyPressed, () => {
     });
   });
 
+  describe(Keys.p, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.p };
+
+    describe('when on the queue view', () => {
+      it('should set a global action to move the song down the queue', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(
+          { ...stateQueue, queue: { ...stateQueue.queue, active: 75 } },
+          action,
+        );
+        expect(result.globalAction).toStrictEqual(queueOrdered(75, 1));
+      });
+    });
+  });
+
+  describe(Keys.P, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.P };
+
+    describe('when on the queue view', () => {
+      it('should set a global action to move the song up the queue', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(
+          { ...stateQueue, queue: { ...stateQueue.queue, active: 75 } },
+          action,
+        );
+        expect(result.globalAction).toStrictEqual(queueOrdered(75, -1));
+      });
+    });
+  });
+
   describe(Keys.Z, () => {
     const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.Z };
 
@@ -567,6 +635,26 @@ describe(ActionTypeKeyPressed, () => {
         expect(result.globalAction).toStrictEqual(masterSet('some-active-client'));
       });
     });
+
+    describe('when on the queue view', () => {
+      it('should set the globalAction to play the active song', () => {
+        expect.assertions(1);
+
+        const result = cmusUIReducer(
+          { ...stateQueue, queue: { ...stateQueue.queue, active: 75 } },
+          action,
+        );
+
+        expect(result.globalAction).toStrictEqual(
+          stateSet({
+            playing: true,
+            songId: 75,
+            currentTime: 0,
+            seekTime: 0,
+          }),
+        );
+      });
+    });
   });
 
   describe(Keys.esc, () => {

+ 37 - 10
gmus-web/src/components/ui/cmus/reducer/keypress.ts

@@ -1,6 +1,7 @@
-import { masterSet, playPaused, stateSet } from '../../../../actions';
+import { masterSet, playPaused, queuePushed, stateSet } from '../../../../actions';
 import { ActionKeyPressed, Keys } from '../../../../hooks/vim';
 import { CmusUIState, LibraryModeWindow, Overlay, View } from '../types';
+import { handleOrder } from './order';
 import { handleScroll } from './scroll';
 import { withGlobalAction } from './utils';
 
@@ -33,6 +34,17 @@ function toggleExpandArtist(library: CmusUIState['library']): CmusUIState['libra
   return { ...library, expandedArtists: [...library.expandedArtists, library.activeArtist] };
 }
 
+const activateSong = (state: CmusUIState, songId: number): CmusUIState =>
+  withGlobalAction(
+    state,
+    stateSet({
+      playing: true,
+      songId,
+      currentTime: 0,
+      seekTime: 0,
+    }),
+  );
+
 function handleActivate(state: CmusUIState): CmusUIState {
   switch (state.view) {
     case View.Library:
@@ -41,18 +53,16 @@ function handleActivate(state: CmusUIState): CmusUIState {
           return state;
         }
 
-        return withGlobalAction(
-          state,
-          stateSet({
-            playing: true,
-            songId: state.library.activeSongId,
-            currentTime: 0,
-            seekTime: 0,
-          }),
-        );
+        return activateSong(state, state.library.activeSongId);
       }
       return state;
 
+    case View.Queue:
+      if (!state.queue.active) {
+        return state;
+      }
+      return activateSong(state, state.queue.active);
+
     case View.ClientList:
       if (!state.clientList.active) {
         return state;
@@ -73,6 +83,8 @@ export function handleKeyPress(state: CmusUIState, action: ActionKeyPressed): Cm
       return { ...state, view: View.Library };
     case Keys['2']:
       return { ...state, view: View.ClientList };
+    case Keys['3']:
+      return { ...state, view: View.Queue };
 
     case Keys.tab:
       if (state.view === View.Library) {
@@ -106,11 +118,26 @@ export function handleKeyPress(state: CmusUIState, action: ActionKeyPressed): Cm
     case Keys.C:
       return withGlobalAction(state, playPaused());
 
+    case Keys.E:
+      if (
+        state.view === View.Library &&
+        state.library.modeWindow === LibraryModeWindow.SongList &&
+        state.library.activeSongId
+      ) {
+        return withGlobalAction(state, queuePushed(state.library.activeSongId));
+      }
+      return state;
+
     case Keys.J:
       return handleScroll(state, 1);
     case Keys.K:
       return handleScroll(state, -1);
 
+    case Keys.p:
+      return handleOrder(state, 1);
+    case Keys.P:
+      return handleOrder(state, -1);
+
     case Keys.pageDown:
       return handleScroll(state, 20);
     case Keys.pageUp:

+ 16 - 0
gmus-web/src/components/ui/cmus/reducer/order.ts

@@ -0,0 +1,16 @@
+import { queueOrdered } from '../../../../actions';
+import { CmusUIState, View } from '../types';
+import { withGlobalAction } from './utils';
+
+export function handleOrder(state: CmusUIState, delta: -1 | 1): CmusUIState {
+  switch (state.view) {
+    case View.Queue:
+      if (!state.queue.active) {
+        return state;
+      }
+      return withGlobalAction(state, queueOrdered(state.queue.active, delta));
+
+    default:
+      return state;
+  }
+}

+ 12 - 1
gmus-web/src/components/ui/cmus/reducer/reducer.spec.ts

@@ -1,4 +1,5 @@
-import { clientActivated, CmusUIActionType } from '../actions';
+import { Song } from '../../../../types';
+import { clientActivated, CmusUIActionType, queueInfoLoaded } from '../actions';
 import { cmusUIReducer, initialCmusUIState } from './reducer';
 
 describe(CmusUIActionType.ClientActivated, () => {
@@ -10,3 +11,13 @@ describe(CmusUIActionType.ClientActivated, () => {
     expect(result.clientList.active).toBe('some-client');
   });
 });
+
+describe(CmusUIActionType.QueueInfoLoaded, () => {
+  const action = queueInfoLoaded([{ id: 176 } as Song]);
+
+  it('should set the queue info', () => {
+    expect.assertions(1);
+    const result = cmusUIReducer(initialCmusUIState, action);
+    expect(result.queue.info).toStrictEqual([{ id: 176 }]);
+  });
+});

+ 15 - 0
gmus-web/src/components/ui/cmus/reducer/reducer.ts

@@ -31,6 +31,10 @@ export const initialCmusUIState: CmusUIState = {
   clientList: {
     active: null,
   },
+  queue: {
+    info: [],
+    active: null,
+  },
 };
 
 export const CmusUIStateContext = createContext<CmusUIState>(initialCmusUIState);
@@ -54,6 +58,17 @@ export function cmusUIReducer(state: CmusUIState, action: CmusUIAction): CmusUIS
     case CmusUIActionType.ClientActivated:
       return { ...state, clientList: { active: action.payload } };
 
+    case CmusUIActionType.QueueInfoLoaded:
+      return {
+        ...state,
+        queue: {
+          info: action.payload,
+          active: action.payload.some(({ id }) => id === state.queue.active)
+            ? state.queue.active
+            : action.payload[0]?.id ?? null,
+        },
+      };
+
     default:
       return state;
   }

+ 15 - 0
gmus-web/src/components/ui/cmus/reducer/scroll.ts

@@ -65,10 +65,25 @@ function handleScrollLibrary(state: CmusUIState, delta: number): CmusUIState {
   }
 }
 
+function handleScrollQueue(state: CmusUIState, delta: number): CmusUIState {
+  const scrolledItem =
+    scrollThroughItems(state.queue.info, (compare) => compare.id === state.queue.active, delta) ??
+    state.queue.info[0];
+  return {
+    ...state,
+    queue: {
+      ...state.queue,
+      active: scrolledItem?.id ?? null,
+    },
+  };
+}
+
 export function handleScroll(state: CmusUIState, delta: number): CmusUIState {
   switch (state.view) {
     case View.Library:
       return handleScrollLibrary(state, delta);
+    case View.Queue:
+      return handleScrollQueue(state, delta);
     default:
       return {
         ...state,

+ 5 - 0
gmus-web/src/components/ui/cmus/types.ts

@@ -4,6 +4,7 @@ import { Song } from '../../../types/songs';
 export enum View {
   Library = 'Library',
   ClientList = 'Client list',
+  Queue = 'Queue',
 }
 
 export enum Overlay {
@@ -43,4 +44,8 @@ export type CmusUIState = {
   clientList: {
     active: string | null;
   };
+  queue: {
+    info: Song[];
+    active: number | null;
+  };
 };

+ 9 - 0
gmus-web/src/components/ui/cmus/views/help.tsx

@@ -16,6 +16,7 @@ const commandsGeneral: Command[] = [
   { command: '<PageUp>', description: 'select pervious page of list items' },
   { command: '1', description: 'show library view' },
   { command: '2', description: 'show client list' },
+  { command: '3', description: 'show queue' },
   { command: ':q', description: 'log out' },
   { command: '<Esc>', description: 'close this dialog' },
 ];
@@ -30,6 +31,13 @@ const commandsClientList: Command[] = [
   { command: '<Enter>', description: 'set the selected client to master' },
 ];
 
+const commandsQueue: Command[] = [
+  { command: 'd', description: 'remove the selected song from the queue' },
+  { command: 'P', description: 'move the selected song up the queue' },
+  { command: 'p', description: 'move the selected song down the queue' },
+  { command: '<Enter>', description: 'play the selected song' },
+];
+
 type CommandGroup = {
   title: string;
   commands: Command[];
@@ -39,6 +47,7 @@ const commandGroups: CommandGroup[] = [
   { title: 'General', commands: commandsGeneral },
   { title: 'Library view', commands: commandsLibrary },
   { title: 'Client list', commands: commandsClientList },
+  { title: 'Queue', commands: commandsQueue },
 ];
 
 export const HelpDialog: React.FC = () => (

+ 28 - 0
gmus-web/src/components/ui/cmus/views/queue.styles.ts

@@ -0,0 +1,28 @@
+import { rem } from 'polished';
+import styled from 'styled-components';
+
+import { ActiveHighlightRow, FlexColumn } from '../styled/layout';
+
+export const Container = styled(FlexColumn)`
+  width: 100%;
+`;
+
+export const QueueSong = styled(ActiveHighlightRow)`
+  width: 100%;
+`;
+
+export const Track = styled.span`
+  flex: 0 0 ${rem(64)};
+`;
+
+export const Title = styled.span`
+  flex: 1;
+`;
+
+export const Artist = styled.span`
+  flex: 1;
+`;
+
+export const Album = styled.span`
+  flex: 1;
+`;

+ 70 - 0
gmus-web/src/components/ui/cmus/views/queue.tsx

@@ -0,0 +1,70 @@
+import { AxiosInstance, AxiosResponse } from 'axios';
+import React, { useCallback, useContext, useEffect, useMemo } from 'react';
+
+import { StateContext } from '../../../../context/state';
+import { useRequestCallback } from '../../../../hooks/request';
+import { Song } from '../../../../types';
+import { getApiUrl } from '../../../../utils/url';
+import { queueInfoLoaded } from '../actions';
+import { CmusUIDispatchContext, CmusUIStateContext } from '../reducer';
+
+import * as Styled from './queue.styles';
+
+export type Props = {
+  currentSong: Song | null;
+};
+
+export const ViewQueue: React.FC<Props> = ({ currentSong }) => {
+  const {
+    player: { queue },
+  } = useContext(StateContext);
+
+  const dispatchUI = useContext(CmusUIDispatchContext);
+  const {
+    queue: { active },
+  } = useContext(CmusUIStateContext);
+
+  const sendRequest = useCallback(
+    (axios: AxiosInstance, query: number[]): Promise<AxiosResponse<Song[]>> =>
+      axios.get(`${getApiUrl()}/multi-song-info?${query.map((id) => `ids=${id}`).join('&')}`),
+    [],
+  );
+
+  const [fetchQueueInfo, queueInfo] = useRequestCallback<number[], Song[]>({
+    sendRequest,
+  });
+
+  useEffect(() => {
+    fetchQueueInfo(queue);
+  }, [fetchQueueInfo, queue]);
+
+  const orderedSongInfo = useMemo<Song[]>(
+    () =>
+      queue
+        .map((id) => queueInfo?.find((compare) => compare.id === id))
+        .filter((info: Song | undefined): info is Song => !!info),
+    [queueInfo, queue],
+  );
+
+  useEffect(() => {
+    dispatchUI(queueInfoLoaded(orderedSongInfo));
+  }, [dispatchUI, orderedSongInfo]);
+
+  return (
+    <Styled.Container>
+      {orderedSongInfo.map((song) => (
+        <Styled.QueueSong
+          key={song.id}
+          active={active === song.id}
+          parentActive={true}
+          highlight={currentSong?.id === song.id}
+        >
+          <Styled.Track>{song.track}</Styled.Track>
+          <Styled.Title>{song.title}</Styled.Title>
+          <Styled.Artist>{song.artist}</Styled.Artist>
+          <Styled.Album>{song.album}</Styled.Album>
+        </Styled.QueueSong>
+      ))}
+    </Styled.Container>
+  );
+};

+ 2 - 0
gmus-web/src/components/ui/cmus/wrapper.tsx

@@ -19,6 +19,7 @@ import { ViewClientList } from './views/clients';
 import { CommandView } from './views/command';
 import { HelpDialog } from './views/help';
 import { ViewLibrary } from './views/library';
+import { ViewQueue } from './views/queue';
 import { PlayerStatus } from './views/status';
 
 import * as Styled from './wrapper.styles';
@@ -70,6 +71,7 @@ export const CmusUIProvider: UIProviderComponent = ({ currentSong, nextSong, pre
             {(stateUI.view === View.Library || stateUI.view === View.ClientList) && (
               <ViewLibrary currentArtist={currentSong?.artist ?? null} />
             )}
+            {stateUI.view === View.Queue && <ViewQueue currentSong={currentSong} />}
           </Styled.View>
           {showOverlay && (
             <Styled.Overlay>

+ 198 - 5
gmus-web/src/effects/effects.spec.ts

@@ -4,10 +4,16 @@ import {
   ActionTypeRemote,
   masterSet,
   playPaused,
+  queueOrdered,
+  queuePushed,
+  queueRemoved,
+  queueShifted,
   seeked,
+  songInfoFetched,
   stateSet,
 } from '../actions';
 import { GlobalState, initialState } from '../reducer';
+import { Song } from '../types';
 import { MusicPlayer } from '../types/state';
 import { globalEffects } from './effects';
 
@@ -22,6 +28,7 @@ describe(globalEffects.name, () => {
         currentTime: 83,
         seekTime: 87,
         master: 'my-client',
+        queue: [],
       };
 
       const prevState: GlobalState = {
@@ -29,7 +36,9 @@ describe(globalEffects.name, () => {
         myClientName: 'my-client-name',
       };
 
-      const result = globalEffects(prevState, stateSet(localPlayer));
+      const action = stateSet(localPlayer);
+
+      const result = globalEffects(prevState, action);
 
       expect(result).toStrictEqual<ActionStateSetRemote>({
         type: ActionTypeRemote.StateSet,
@@ -47,6 +56,7 @@ describe(globalEffects.name, () => {
         currentTime: 83,
         seekTime: 87,
         master: 'my-client-name',
+        queue: [],
       },
       myClientName: 'my-client-name',
     };
@@ -57,6 +67,8 @@ describe(globalEffects.name, () => {
       myClientName: 'some-slave-client',
     };
 
+    const action = seeked(776);
+
     describe.each`
       clientType   | state
       ${'master'}  | ${stateMaster}
@@ -65,7 +77,7 @@ describe(globalEffects.name, () => {
       it('should create a remote state set action', () => {
         expect.assertions(1);
 
-        const result = globalEffects(state, seeked(776));
+        const result = globalEffects(state, action);
 
         expect(result).toStrictEqual<ActionStateSetRemote>({
           type: ActionTypeRemote.StateSet,
@@ -85,13 +97,16 @@ describe(globalEffects.name, () => {
         currentTime: 83,
         seekTime: 5,
         master: 'some-master-went-away',
+        queue: [],
       },
       myClientName: 'my-client-name',
     };
 
+    const action = masterSet();
+
     it('should return a StateSet action informing other clients that we are the new master', () => {
       expect.assertions(1);
-      const result = globalEffects(stateMasterWentAway, masterSet());
+      const result = globalEffects(stateMasterWentAway, action);
 
       expect(result).toStrictEqual<ActionStateSetRemote>({
         type: ActionTypeRemote.StateSet,
@@ -101,6 +116,7 @@ describe(globalEffects.name, () => {
           currentTime: 83,
           seekTime: -1,
           master: 'my-client-name',
+          queue: [],
         },
       });
     });
@@ -118,6 +134,7 @@ describe(globalEffects.name, () => {
             currentTime: 83,
             seekTime: 83,
             master: 'other-client',
+            queue: [],
           },
         });
       });
@@ -133,14 +150,17 @@ describe(globalEffects.name, () => {
         currentTime: 83,
         seekTime: 5,
         master: 'some-master-client',
+        queue: [],
       },
       myClientName: 'some-master-client',
     };
 
+    const action = playPaused();
+
     describe('when the client is master', () => {
       it('should return null', () => {
         expect.assertions(1);
-        expect(globalEffects(statePriorMaster, playPaused())).toBeNull();
+        expect(globalEffects(statePriorMaster, action)).toBeNull();
       });
     });
 
@@ -152,7 +172,7 @@ describe(globalEffects.name, () => {
 
       it('should return a StateSet action informing other clients of the updated playing state', () => {
         expect.assertions(1);
-        const result = globalEffects(stateSlave, playPaused());
+        const result = globalEffects(stateSlave, action);
 
         expect(result).toStrictEqual<ActionStateSetRemote>({
           type: ActionTypeRemote.StateSet,
@@ -162,9 +182,182 @@ describe(globalEffects.name, () => {
             currentTime: 83,
             seekTime: 5,
             master: 'some-master-client',
+            queue: [],
           },
         });
       });
     });
   });
+
+  describe(ActionTypeLocal.SongInfoFetched, () => {
+    const statePriorMaster: GlobalState = {
+      ...initialState,
+      player: {
+        songId: 123,
+        playing: true,
+        currentTime: 83,
+        seekTime: 5,
+        master: 'some-master-client',
+        queue: [],
+      },
+      myClientName: 'some-master-client',
+    };
+
+    const action = songInfoFetched({ id: 185 } as Song, true);
+
+    describe('when the client is master', () => {
+      it('should return null', () => {
+        expect.assertions(1);
+        expect(globalEffects(statePriorMaster, action)).toBeNull();
+      });
+    });
+
+    describe('when the client is a slave', () => {
+      const stateSlave: GlobalState = {
+        ...statePriorMaster,
+        myClientName: 'some-slave-client',
+      };
+
+      it('should return a StateSet action informing other clients of the changed song', () => {
+        expect.assertions(1);
+        const result = globalEffects(stateSlave, action);
+
+        expect(result).toStrictEqual<ActionStateSetRemote>({
+          type: ActionTypeRemote.StateSet,
+          payload: {
+            songId: 185,
+            playing: true,
+            currentTime: 0,
+            seekTime: 0,
+            master: 'some-master-client',
+            queue: [],
+          },
+        });
+      });
+
+      describe('when the action is not set to replace the current song', () => {
+        const actionNoReplace = songInfoFetched({ id: 185 } as Song, false);
+
+        it('should return null', () => {
+          expect.assertions(1);
+          const result = globalEffects(stateSlave, actionNoReplace);
+          expect(result).toBeNull();
+        });
+      });
+    });
+  });
+
+  describe(ActionTypeLocal.QueuePushed, () => {
+    const action = queuePushed(184);
+
+    it('should add to the end of the queue', () => {
+      expect.assertions(1);
+      const result = globalEffects(
+        {
+          ...initialState,
+          player: { ...initialState.player, master: 'some-master', queue: [23] },
+        },
+        action,
+      );
+      expect(result).toStrictEqual<ActionStateSetRemote>({
+        type: ActionTypeRemote.StateSet,
+        payload: {
+          ...initialState.player,
+          master: 'some-master',
+          queue: [23, 184],
+        },
+      });
+    });
+
+    describe('when the song is already in the queue', () => {
+      it('should not modify the queue', () => {
+        expect.assertions(1);
+        const result = globalEffects(
+          {
+            ...initialState,
+            player: { ...initialState.player, queue: [184, 23] },
+          },
+          action,
+        );
+        expect(result).toBeNull();
+      });
+    });
+  });
+
+  describe(ActionTypeLocal.QueueShifted, () => {
+    const action = queueShifted();
+    const stateWithQueue: GlobalState = {
+      ...initialState,
+      player: { ...initialState.player, master: 'some-master', queue: [8843, 23] },
+    };
+
+    it('should play the first song on the queue and remove it from the queue', () => {
+      expect.assertions(1);
+      const result = globalEffects(stateWithQueue, action);
+      expect(result).toStrictEqual<ActionStateSetRemote>({
+        type: ActionTypeRemote.StateSet,
+        payload: {
+          ...initialState.player,
+          master: 'some-master',
+          playing: true,
+          songId: 8843,
+          currentTime: 0,
+          seekTime: 0,
+          queue: [23],
+        },
+      });
+    });
+  });
+
+  describe(ActionTypeLocal.QueueRemoved, () => {
+    const action = queueRemoved(84);
+
+    it('should remove the given song ID from the queue', () => {
+      expect.assertions(1);
+      const result = globalEffects(
+        {
+          ...initialState,
+          player: { ...initialState.player, master: 'some-master', queue: [17, 84, 23] },
+        },
+        action,
+      );
+
+      expect(result).toStrictEqual<ActionStateSetRemote>({
+        type: ActionTypeRemote.StateSet,
+        payload: {
+          ...initialState.player,
+          master: 'some-master',
+          queue: [17, 23],
+        },
+      });
+    });
+  });
+
+  describe(ActionTypeLocal.QueueOrdered, () => {
+    it.each`
+      direction      | delta | expectedResult
+      ${'forwards'}  | ${1}  | ${[17, 23, 84]}
+      ${'backwards'} | ${-1} | ${[84, 17, 23]}
+    `('should reorder ($direction) the given song ID', ({ delta, expectedResult }) => {
+      const action = queueOrdered(84, delta);
+
+      expect.assertions(1);
+      const result = globalEffects(
+        {
+          ...initialState,
+          player: { ...initialState.player, master: 'some-master', queue: [17, 84, 23] },
+        },
+        action,
+      );
+
+      expect(result).toStrictEqual<ActionStateSetRemote>({
+        type: ActionTypeRemote.StateSet,
+        payload: {
+          ...initialState.player,
+          master: 'some-master',
+          queue: expectedResult,
+        },
+      });
+    });
+  });
 });

+ 87 - 1
gmus-web/src/effects/effects.ts

@@ -1,16 +1,44 @@
-import { ActionTypeLocal, ActionTypeRemote, LocalAction, RemoteAction } from '../actions';
+import {
+  ActionQueueOrdered,
+  ActionTypeLocal,
+  ActionTypeRemote,
+  LocalAction,
+  RemoteAction,
+} from '../actions';
 import { GlobalState } from '../reducer/types';
 import { isMaster } from '../selectors';
 
+const reverseInArray = <T>(array: T[], index: number): T[] => [
+  ...array.slice(0, Math.max(0, index)),
+  ...array.slice(Math.max(0, index), index + 2).reverse(),
+  ...array.slice(index + 2),
+];
+
+function reorderQueue(queue: number[], action: ActionQueueOrdered): number[] {
+  const currentIndex = queue.indexOf(action.payload.songId);
+  if (currentIndex === -1) {
+    return queue;
+  }
+
+  const reverseIndex = action.payload.delta === 1 ? currentIndex : currentIndex - 1;
+  return reverseInArray(queue, reverseIndex);
+}
+
 export function globalEffects(prevState: GlobalState, action: LocalAction): RemoteAction | null {
   switch (action.type) {
     case ActionTypeLocal.StateSet:
+      if (!prevState.player.master && !action.payload.master) {
+        return null;
+      }
       return {
         type: ActionTypeRemote.StateSet,
         payload: { ...prevState.player, ...action.payload },
       };
 
     case ActionTypeLocal.Seeked:
+      if (!prevState.player.master) {
+        return null;
+      }
       return {
         type: ActionTypeRemote.StateSet,
         payload: { ...prevState.player, seekTime: action.payload },
@@ -50,6 +78,64 @@ export function globalEffects(prevState: GlobalState, action: LocalAction): Remo
         },
       };
 
+    case ActionTypeLocal.SongInfoFetched:
+      if (isMaster(prevState) || !action.payload.replace || !prevState.player.master) {
+        return null;
+      }
+      return {
+        type: ActionTypeRemote.StateSet,
+        payload: {
+          ...prevState.player,
+          songId: action.payload.song?.id ?? null,
+          playing: !!action.payload.song,
+          currentTime: 0,
+          seekTime: 0,
+        },
+      };
+
+    case ActionTypeLocal.QueuePushed:
+      if (!prevState.player.master || prevState.player.queue.includes(action.payload)) {
+        return null;
+      }
+      return {
+        type: ActionTypeRemote.StateSet,
+        payload: {
+          ...prevState.player,
+          queue: [...prevState.player.queue, action.payload],
+        },
+      };
+    case ActionTypeLocal.QueueShifted:
+      if (!prevState.player.master) {
+        return null;
+      }
+      return {
+        type: ActionTypeRemote.StateSet,
+        payload: {
+          ...prevState.player,
+          queue: prevState.player.queue.slice(1),
+          playing: !!prevState.player.queue[0],
+          songId: prevState.player.queue[0],
+          currentTime: 0,
+          seekTime: 0,
+        },
+      };
+    case ActionTypeLocal.QueueRemoved:
+      if (!prevState.player.master) {
+        return null;
+      }
+      return {
+        type: ActionTypeRemote.StateSet,
+        payload: {
+          ...prevState.player,
+          queue: prevState.player.queue.filter((id) => id !== action.payload),
+        },
+      };
+    case ActionTypeLocal.QueueOrdered:
+      return {
+        type: ActionTypeRemote.StateSet,
+        payload: { ...prevState.player, queue: reorderQueue(prevState.player.queue, action) },
+      };
+
     default:
       return null;
   }

+ 2 - 0
gmus-web/src/hooks/master.spec.tsx

@@ -80,6 +80,7 @@ describe(useMaster.name, () => {
         currentTime: 17,
         seekTime: -1,
         master: 'master-client-a',
+        queue: [],
       },
     };
 
@@ -226,3 +227,4 @@ describe(useMaster.name, () => {
     });
   });
 });
+

+ 25 - 5
gmus-web/src/hooks/queue.ts

@@ -1,8 +1,8 @@
 import { useThrottleCallback } from '@react-hook/throttle';
 import { AxiosInstance, AxiosResponse } from 'axios';
 import { Dispatch, useCallback, useContext, useEffect } from 'react';
-import { LocalAction, songInfoFetched, stateSet } from '../actions';
-import { DispatchContext } from '../context/state';
+import { LocalAction, queueShifted, songInfoFetched, stateSet } from '../actions';
+import { DispatchContext, StateContext } from '../context/state';
 import { NullSong, Song, songExists } from '../types';
 import { getApiUrl } from '../utils/url';
 
@@ -38,14 +38,34 @@ function useNextOrPrevSong(
 }
 
 export function usePlayQueue(): {
-  onNext: (songId: number) => void;
-  onPrev: (songId: number) => void;
+  onNext: () => void;
+  onPrev: () => void;
   loading: boolean;
 } {
   const dispatch = useContext(DispatchContext);
+  const {
+    player: { queue, songId },
+  } = useContext(StateContext);
 
   const [onRequestNext, loadingNext] = useNextOrPrevSong('next', dispatch);
   const [onRequestPrev, loadingPrev] = useNextOrPrevSong('prev', dispatch);
 
-  return { onNext: onRequestNext, onPrev: onRequestPrev, loading: loadingNext || loadingPrev };
+  const loading = loadingNext || loadingPrev;
+
+  const firstQueuedSongId = queue[0];
+  const onNext = useCallback(() => {
+    if (firstQueuedSongId) {
+      dispatch(queueShifted());
+    } else if (!loading && songId) {
+      onRequestNext(songId);
+    }
+  }, [dispatch, firstQueuedSongId, songId, loading, onRequestNext]);
+
+  const onPrev = useCallback(() => {
+    if (!loading && songId) {
+      onRequestPrev(songId);
+    }
+  }, [songId, loading, onRequestPrev]);
+
+  return { onNext, onPrev, loading };
 }

+ 1 - 1
gmus-web/src/hooks/status.spec.tsx

@@ -105,8 +105,8 @@ describe(useCurrentlyPlayingSongInfo.name, () => {
           .reply(200, testSong, { 'Access-Control-Allow-Origin': '*' });
       });
 
+      // eslint-disable-next-line jest/prefer-expect-assertions
       it('should fetch the info for the updated song ID, and update the state', async () => {
-        expect.assertions(3);
         setup(stateStale);
         await waitFor(() => {
           expect(dispatch).toHaveBeenCalledTimes(1);

+ 4 - 0
gmus-web/src/hooks/vim.ts

@@ -12,10 +12,14 @@ export const Keys = {
   pageUp: 'PageUp',
   '1': '1',
   '2': '2',
+  '3': '3',
   B: 'b',
   C: 'c',
+  E: 'e',
   J: 'j',
   K: 'k',
+  P: 'P',
+  p: 'p',
   Z: 'z',
 };
 

+ 4 - 1
gmus-web/src/index.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
+import { StateInspector } from 'reinspect';
 import { Reset } from 'styled-reset';
 
 import { Root } from './components/root';
@@ -8,7 +9,9 @@ import reportWebVitals from './reportWebVitals';
 ReactDOM.render(
   <React.StrictMode>
     <Reset />
-    <Root />
+    <StateInspector name="global">
+      <Root />
+    </StateInspector>
   </React.StrictMode>,
   document.getElementById('root'),
 );

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

@@ -29,6 +29,7 @@ describe(globalReducer.name, () => {
           currentTime: 0,
           seekTime: -1,
           master: 'some-master-client',
+          queue: [],
         },
       });
 
@@ -57,6 +58,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'some-master-client',
+            queue: [],
           },
         };
 
@@ -70,6 +72,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'some-master-client',
+            queue: [],
           });
         });
       });
@@ -84,6 +87,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'some-master-client',
+            queue: [],
           },
         };
 
@@ -97,6 +101,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: -1,
             master: 'some-master-client',
+            queue: [],
           });
         });
       });
@@ -111,6 +116,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'other-master-client',
+            queue: [],
           },
         };
 
@@ -124,6 +130,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: -1,
             master: 'other-master-client',
+            queue: [],
           });
         });
       });
@@ -153,6 +160,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'some-master-client',
+            queue: [],
           },
         };
 
@@ -160,12 +168,13 @@ describe(globalReducer.name, () => {
           expect.assertions(1);
           const result = globalReducer(stateSlave, action);
 
-          expect(result.player).toStrictEqual({
+          expect(result.player).toStrictEqual<MusicPlayer>({
             songId: 123,
             playing: true,
             currentTime: 75,
             seekTime: -1,
             master: 'some-master-client',
+            queue: [],
           });
         });
       });
@@ -180,6 +189,7 @@ describe(globalReducer.name, () => {
             currentTime: 75,
             seekTime: 87,
             master: 'my-client',
+            queue: [],
           },
         };
 
@@ -187,12 +197,13 @@ describe(globalReducer.name, () => {
           expect.assertions(1);
           const result = globalReducer(stateSlave, actionToMaster);
 
-          expect(result.player).toStrictEqual({
+          expect(result.player).toStrictEqual<MusicPlayer>({
             songId: 123,
             playing: true,
             currentTime: 75,
             seekTime: 87,
             master: 'my-client',
+            queue: [],
           });
         });
       });
@@ -261,6 +272,7 @@ describe(globalReducer.name, () => {
           currentTime: 75,
           seekTime: 87,
           master: 'some-master-client',
+          queue: [],
         });
       });
 
@@ -275,7 +287,7 @@ describe(globalReducer.name, () => {
 
           const result = globalReducer(stateMaster, actionPartial);
 
-          expect(result.player).toStrictEqual({
+          expect(result.player).toStrictEqual<MusicPlayer>({
             ...nullPlayer,
             master: 'some-master-client',
             currentTime: 31,
@@ -331,6 +343,7 @@ describe(globalReducer.name, () => {
           currentTime: 31,
           seekTime: -1,
           master: 'some-master-client',
+          queue: [],
         },
         myClientName: 'some-master-client',
       };
@@ -345,6 +358,7 @@ describe(globalReducer.name, () => {
           currentTime: 31,
           seekTime: 173,
           master: 'some-master-client',
+          queue: [],
         });
       });
     });
@@ -381,6 +395,7 @@ describe(globalReducer.name, () => {
         master: 'some-master-client',
         currentTime: 13,
         seekTime: -1,
+        queue: [],
       },
     };
 

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

@@ -16,6 +16,7 @@ export const nullPlayer: MusicPlayer = {
   currentTime: 0,
   seekTime: -1,
   master: '',
+  queue: [],
 };
 
 export const initialState: GlobalState = {

+ 1 - 0
gmus-web/src/types/state.ts

@@ -9,4 +9,5 @@ export type MusicPlayer = {
   currentTime: number;
   seekTime: number;
   master: string;
+  queue: number[];
 };

+ 0 - 19
gmus-web/yarn.lock

@@ -4814,13 +4814,6 @@ 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"
@@ -5175,11 +5168,6 @@ 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"
@@ -9184,13 +9172,6 @@ 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"