Procházet zdrojové kódy

feat: skip to next / previous song

    * feat: replace song info when skipping to next / previous song
    * refactor: less verbose next/previous song queries
    * refactor: store currently playing song info in global state
    * feat: return cancel callback from request hook
    * feat(wip): making next/previous song logic consistent with order by logic in songs list
    * feat: keyboard commands to skip tracks
    * refactor: split up cmus reducer
    * feat: previous / next track handling for tracks without any information
    * feat: play next track when current one ends
    * refactor: moved state and dispatch context to root component
    * feat: hook for next / previous song
    * fix: don't throw an unhandled error when song ID does not exist
    * refactor: useRequestCallback for all request hooks
    * fix: set API port by default on development to 3002
    * feat: request async callback hook
    * feat: routes to get next and previous song info
    * feat: repository method to select previous track
    * feat: repository method to query the next song
    * feat: indexes on track number, title, artist and album
    * chore: prefixed commands
    * chore: updated docker compose file
    * feat: default command in dockerfile to start server
    * feat: optional host to listen on
Fela Maslen před 5 roky
rodič
revize
a958d33941
54 změnil soubory, kde provedl 2075 přidání a 1269 odebrání
  1. 2 0
      gmus-backend/Dockerfile
  2. 6 6
      gmus-backend/Makefile
  3. 8 0
      gmus-backend/README.md
  4. 0 0
      gmus-backend/cmd/gmus.migrate/main.go
  5. 0 0
      gmus-backend/cmd/gmus.scan/main.go
  6. 0 0
      gmus-backend/cmd/gmus.server/main.go
  7. 19 13
      gmus-backend/docker-compose.yml
  8. 4 0
      gmus-backend/migrations/000005_sort_indexes.down.sql
  9. 4 0
      gmus-backend/migrations/000005_sort_indexes.up.sql
  10. 10 0
      gmus-backend/pkg/config/config.go
  11. 1 0
      gmus-backend/pkg/config/main.go
  12. 1 0
      gmus-backend/pkg/read/types.go
  13. 120 0
      gmus-backend/pkg/repository/player.go
  14. 150 0
      gmus-backend/pkg/repository/player_test.go
  15. 1 1
      gmus-backend/pkg/repository/songs.go
  16. 77 7
      gmus-backend/pkg/server/fetch.go
  17. 5 2
      gmus-backend/pkg/server/server.go
  18. 1 1
      gmus-web/.env.example
  19. 13 1
      gmus-web/src/actions/actions.ts
  20. 1 0
      gmus-web/src/actions/types.ts
  21. 41 17
      gmus-web/src/components/app.tsx
  22. 3 0
      gmus-web/src/components/player.tsx
  23. 8 1
      gmus-web/src/components/root.tsx
  24. 0 731
      gmus-web/src/components/ui/cmus/reducer.spec.ts
  25. 0 294
      gmus-web/src/components/ui/cmus/reducer.ts
  26. 87 0
      gmus-web/src/components/ui/cmus/reducer/artists.spec.ts
  27. 29 0
      gmus-web/src/components/ui/cmus/reducer/artists.ts
  28. 18 0
      gmus-web/src/components/ui/cmus/reducer/command.spec.ts
  29. 15 0
      gmus-web/src/components/ui/cmus/reducer/command.ts
  30. 34 0
      gmus-web/src/components/ui/cmus/reducer/fixtures.ts
  31. 1 0
      gmus-web/src/components/ui/cmus/reducer/index.ts
  32. 611 0
      gmus-web/src/components/ui/cmus/reducer/keypress.spec.ts
  33. 122 0
      gmus-web/src/components/ui/cmus/reducer/keypress.ts
  34. 12 0
      gmus-web/src/components/ui/cmus/reducer/reducer.spec.ts
  35. 60 0
      gmus-web/src/components/ui/cmus/reducer/reducer.ts
  36. 78 0
      gmus-web/src/components/ui/cmus/reducer/scroll.ts
  37. 8 0
      gmus-web/src/components/ui/cmus/reducer/utils.ts
  38. 4 0
      gmus-web/src/components/ui/cmus/types.ts
  39. 2 0
      gmus-web/src/components/ui/cmus/views/help.tsx
  40. 15 2
      gmus-web/src/components/ui/cmus/wrapper.tsx
  41. 2 0
      gmus-web/src/components/ui/types.ts
  42. 53 78
      gmus-web/src/hooks/fetch/artists.ts
  43. 23 12
      gmus-web/src/hooks/master.spec.tsx
  44. 6 4
      gmus-web/src/hooks/master.ts
  45. 51 0
      gmus-web/src/hooks/queue.ts
  46. 125 0
      gmus-web/src/hooks/request.spec.tsx
  47. 53 55
      gmus-web/src/hooks/request.ts
  48. 97 24
      gmus-web/src/hooks/status.spec.tsx
  49. 30 20
      gmus-web/src/hooks/status.ts
  50. 2 0
      gmus-web/src/hooks/vim.ts
  51. 35 0
      gmus-web/src/reducer/reducer.spec.ts
  52. 21 0
      gmus-web/src/reducer/reducer.ts
  53. 2 0
      gmus-web/src/reducer/types.ts
  54. 4 0
      gmus-web/src/types/songs.ts

+ 2 - 0
gmus-backend/Dockerfile

@@ -15,3 +15,5 @@ RUN addgroup -S appgroup && adduser -S appuser -G appgroup
 USER appuser
 
 ENV PATH="/app/bin:${PATH}"
+
+CMD gmus.server

+ 6 - 6
gmus-backend/Makefile

@@ -1,9 +1,9 @@
 build.scan:
-	go build -o bin/scan ./cmd/scan
+	go build -o bin/gmus.scan ./cmd/gmus.scan
 build.migrate:
-	go build -o bin/migrate ./cmd/migrate
+	go build -o bin/gmus.migrate ./cmd/gmus.migrate
 build.server:
-	go build -o bin/server ./cmd/server
+	go build -o bin/gmus.server ./cmd/gmus.server
 
 build:
 	make build.scan
@@ -20,11 +20,11 @@ test:
 	GO_ENV=test ginkgo ./...
 
 migrate:
-	go run ./cmd/migrate
+	go run ./cmd/gmus.migrate
 migrate.make:
 	migrate create -ext sql -dir migrations -seq $(NAME)
 
 run.scan:
-	GO_ENV=development go run ./cmd/scan
+	GO_ENV=development go run ./cmd/gmus.scan
 run.server:
-	GO_ENV=development go run ./cmd/server
+	GO_ENV=development go run ./cmd/gmus.server

+ 8 - 0
gmus-backend/README.md

@@ -71,3 +71,11 @@ Fetches songs for a particular artist, and outputs them in JSON format.
 #### `GET /song-info?id=<id>`
 
 Fetches info for a particular song, based on its ID, and outputs it in JSON format.
+
+#### `GET /next-song?id=<id>`
+
+Fetches next song info.
+
+#### `GET /prev-song?id=<id>`
+
+Fetches previous song info.

+ 0 - 0
gmus-backend/cmd/migrate/main.go → gmus-backend/cmd/gmus.migrate/main.go


+ 0 - 0
gmus-backend/cmd/scan/main.go → gmus-backend/cmd/gmus.scan/main.go


+ 0 - 0
gmus-backend/cmd/server/main.go → gmus-backend/cmd/gmus.server/main.go


+ 19 - 13
gmus-backend/docker-compose.yml

@@ -1,31 +1,37 @@
 version: "2"
 services:
-  gmusic:
-    container_name: gmusic
+  gmus-backend:
+    container_name: gmus-backend
     build:
       context: .
     volumes:
       - .:/app
+      - /app/bin
     links:
-      - music-player-db:db
-      - music-player-redis:redis
+      - gmus-db:db
+      - gmus-redis:redis
     networks:
-      - net_gmusic_dev
+      - net_gmus_dev
     ports:
-      - ${PORT}:3000
+      - 3002:80
     env_file:
       - .env
     environment:
       GO_ENV: development
-      PORT: 3000
-      DATABASE_URL: postgres://docker:docker@db:5432/music_player
+      HOST: 0.0.0.0
+      PORT: 80
+      POSTGRES_HOST: db
+      POSTGRES_DATABASE: music_player
+      POSTGRES_USER: docker
+      POSTGRES_PASSWORD: docker
+      POSTGRES_PORT: 5432
       REDIS_URL: redis:6379
 
-  music-player-db:
+  gmus-db:
     restart: always
     image: postgres:10.4
     networks:
-      - net_gmusic_dev
+      - net_gmus_dev
     ports:
       - 5417:5432
     environment:
@@ -34,14 +40,14 @@ services:
     volumes:
       - ./init.sql:/docker-entrypoint-initdb.d/init.sql
 
-  music-player-redis:
+  gmus-redis:
     restart: always
     image: redis:6-alpine
     networks:
-      - net_gmusic_dev
+      - net_gmus_dev
     ports:
       - 6381:6379
 
 networks:
-  net_gmusic_dev:
+  net_gmus_dev:
     driver: bridge

+ 4 - 0
gmus-backend/migrations/000005_sort_indexes.down.sql

@@ -0,0 +1,4 @@
+DROP INDEX songs_track_number;
+DROP INDEX songs_title;
+DROP INDEX songs_album;
+DROP INDEX songs_artist;

+ 4 - 0
gmus-backend/migrations/000005_sort_indexes.up.sql

@@ -0,0 +1,4 @@
+CREATE INDEX songs_artist ON songs (artist);
+CREATE INDEX songs_album ON songs (album);
+CREATE INDEX songs_title ON songs (title);
+CREATE INDEX songs_track_number ON songs (track_number);

+ 10 - 0
gmus-backend/pkg/config/config.go

@@ -109,6 +109,15 @@ func getPort() int {
 	return result
 }
 
+func getListenHost() string {
+	var defaultHost = "localhost"
+	host, hasHost := os.LookupEnv("HOST")
+	if !hasHost {
+		return defaultHost
+	}
+	return host
+}
+
 func getRedisUrl() string {
 	url, hasUrl := os.LookupEnv("REDIS_URL")
 	if !hasUrl {
@@ -121,6 +130,7 @@ type config struct {
 	DatabaseUrl      string
 	LogLevel         logger.LogLevel
 	LibraryDirectory string
+	Host             string
 	Port             int
 	RedisUrl         string
 }

+ 1 - 0
gmus-backend/pkg/config/main.go

@@ -11,6 +11,7 @@ func GetConfig() config {
 		DatabaseUrl:      getDatabaseUrl(),
 		LogLevel:         getLogLevel(),
 		LibraryDirectory: os.Getenv("LIBRARY_DIRECTORY"),
+		Host:             getListenHost(),
 		Port:             getPort(),
 		RedisUrl:         getRedisUrl(),
 	}

+ 1 - 0
gmus-backend/pkg/read/types.go

@@ -1,6 +1,7 @@
 package read
 
 type Song struct {
+	Id           int    `db:"id"`
 	TrackNumber  int    `db:"track_number"`
 	Title        string `db:"title"`
 	Artist       string `db:"artist"`

+ 120 - 0
gmus-backend/pkg/repository/player.go

@@ -0,0 +1,120 @@
+package repository
+
+import (
+	"database/sql"
+
+	"github.com/felamaslen/gmus-backend/pkg/read"
+	"github.com/jmoiron/sqlx"
+)
+
+func GetNextSong(db *sqlx.DB, prevSongId int) (nextSong *read.Song, err error) {
+	nextSong = &read.Song{}
+	err = db.QueryRowx(
+		`
+select
+  s1.id
+  ,s1.track_number
+  ,s1.title
+  ,s1.artist
+  ,s1.album
+  ,s1.duration
+
+from (
+  select * from songs where id = $1
+) s0
+
+left join songs s1 on (
+  s1.artist > s0.artist
+  or (s1.artist = s0.artist
+    and s1.album > s0.album
+  )
+  or (s1.artist = s0.artist
+    and s1.album = s0.album
+    and s1.track_number > s0.track_number
+  )
+  or (s1.artist = s0.artist
+    and s1.album = s0.album
+    and s1.track_number = s0.track_number
+    and s1.title > s0.title
+  )
+  or (s1.artist = s0.artist
+    and s1.album = s0.album
+    and s1.track_number = s0.track_number
+    and s1.title = s0.title
+    and s1.id > s0.id
+  )
+)
+
+order by
+  s1.artist
+  ,s1.album
+  ,s1.track_number
+  ,s1.title
+  ,s1.id
+
+limit 1
+    `,
+		prevSongId,
+	).StructScan(nextSong)
+	if err != nil && err == sql.ErrNoRows {
+		err = nil
+		nextSong = &read.Song{Id: 0}
+	}
+	return
+}
+
+func GetPrevSong(db *sqlx.DB, nextSongId int) (prevSong *read.Song, err error) {
+	prevSong = &read.Song{}
+	err = db.QueryRowx(
+		`
+select
+  s1.id
+  ,s1.track_number
+  ,s1.title
+  ,s1.artist
+  ,s1.album
+  ,s1.duration
+
+from (
+  select * from songs where id = $1
+) s0
+
+left join songs s1 on (
+  s1.artist < s0.artist
+  or (s1.artist = s0.artist
+    and s1.album < s0.album
+  )
+  or (s1.artist = s0.artist
+    and s1.album = s0.album
+    and s1.track_number < s0.track_number
+  )
+  or (s1.artist = s0.artist
+    and s1.album = s0.album
+    and s1.track_number = s0.track_number
+    and s1.title < s0.title
+  )
+  or (s1.artist = s0.artist
+    and s1.album = s0.album
+    and s1.track_number = s0.track_number
+    and s1.title = s0.title
+    and s1.id < s0.id
+  )
+)
+
+order by
+  s1.artist desc
+  ,s1.album desc
+  ,s1.track_number desc
+  ,s1.title desc
+  ,s1.id desc
+
+limit 1
+    `,
+		nextSongId,
+	).StructScan(prevSong)
+	if err != nil && err == sql.ErrNoRows {
+		err = nil
+		prevSong = &read.Song{Id: 0}
+	}
+	return
+}

+ 150 - 0
gmus-backend/pkg/repository/player_test.go

@@ -0,0 +1,150 @@
+package repository_test
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
+	"github.com/felamaslen/gmus-backend/pkg/database"
+	"github.com/felamaslen/gmus-backend/pkg/repository"
+	"github.com/felamaslen/gmus-backend/pkg/testing"
+)
+
+var _ = Describe("Player repository", func() {
+	db := database.GetConnection()
+
+	var ids []int
+
+	BeforeEach(func() {
+		testing.PrepareDatabaseForTesting()
+
+		rows, err := db.Queryx(
+			`
+      insert into songs (
+        track_number
+        ,title
+        ,artist
+        ,album
+        ,duration
+        ,modified_date
+        ,base_path
+        ,relative_path
+      )
+      select * from unnest(
+        array[1, 1, 2, 4, 0, 0]
+        ,array['T1', 'T2', 'T3', 'T4', '', '']
+        ,array['AR1', 'AR2', 'AR1', 'AR1', '', '']
+        ,array['AL1', 'AL2', 'AL1', 'AL3', '', '']
+        ,array[100, 200, 300, 250, 103, 107]
+        ,array[123, 456, 789, 120, 883, 1443]
+        ,array['/music', '/music', '/music', '/music', '/music', '/music']
+        ,array['file1.ogg', 'file2.ogg', 'file3.ogg', 'file4.ogg', 'file5.ogg', 'file6.ogg']
+      )
+      returning id
+      `,
+		)
+		if err != nil {
+			panic(err)
+		}
+
+		var id int
+		ids = []int{}
+
+		for rows.Next() {
+			rows.Scan(&id)
+			ids = append(ids, id)
+		}
+		rows.Close()
+	})
+
+	Describe("GetNextSong", func() {
+		Context("when another song exists in the same album", func() {
+			It("should return the correct song ID", func() {
+				song, _ := repository.GetNextSong(db, ids[0])
+				Expect(song.Id).To(Equal(ids[2]))
+			})
+		})
+
+		Context("when another song exists from the same artist", func() {
+			It("should return the correct song ID", func() {
+				song, _ := repository.GetNextSong(db, ids[2])
+				Expect(song.Id).To(Equal(ids[3]))
+			})
+		})
+
+		Context("when another song exists by a different artist", func() {
+			It("should return the correct song ID", func() {
+				song, _ := repository.GetNextSong(db, ids[3])
+				Expect(song.Id).To(Equal(ids[1]))
+			})
+		})
+
+		Context("when no further songs exist", func() {
+			It("should return zero", func() {
+				song, _ := repository.GetNextSong(db, ids[1])
+				Expect(song.Id).To(Equal(0))
+			})
+		})
+
+		Context("when the song has no information", func() {
+			It("should return the next song by ID", func() {
+				song, _ := repository.GetNextSong(db, ids[4])
+				Expect(song.Id).To(Equal(ids[5]))
+			})
+		})
+
+		Context("when the ID does not exist", func() {
+			It("should return nil", func() {
+				song, err := repository.GetNextSong(db, 10000000)
+
+				Expect(err).To(BeNil())
+				Expect(song.Id).To(BeZero())
+			})
+		})
+	})
+
+	Describe("GetPrevSong", func() {
+		Context("when another song exists in the same album", func() {
+			It("should return the correct song ID", func() {
+				song, _ := repository.GetPrevSong(db, ids[2])
+				Expect(song.Id).To(Equal(ids[0]))
+			})
+		})
+
+		Context("when another song exists from the same artist", func() {
+			It("should return the correct song ID", func() {
+				song, _ := repository.GetPrevSong(db, ids[3])
+				Expect(song.Id).To(Equal(ids[2]))
+			})
+		})
+
+		Context("when another song exists by a different artist", func() {
+			It("should return the correct song ID", func() {
+				song, _ := repository.GetPrevSong(db, ids[1])
+				Expect(song.Id).To(Equal(ids[3]))
+			})
+		})
+
+		Context("when the song has no information", func() {
+			It("should return the previous song by ID", func() {
+				song, _ := repository.GetPrevSong(db, ids[5])
+				Expect(song.Id).To(Equal(ids[4]))
+			})
+		})
+
+		Context("when no further songs exist", func() {
+			It("should return zero", func() {
+				song, _ := repository.GetPrevSong(db, ids[4])
+				Expect(song.Id).To(Equal(0))
+			})
+		})
+
+		Context("when the ID does not exist", func() {
+			It("should return nil", func() {
+				song, err := repository.GetPrevSong(db, 10000000)
+
+				Expect(err).To(BeNil())
+				Expect(song.Id).To(BeZero())
+			})
+		})
+	})
+})

+ 1 - 1
gmus-backend/pkg/repository/songs.go

@@ -96,7 +96,7 @@ func SelectSongsByArtist(db *sqlx.DB, artist string) (songs *[]*read.SongExterna
     ,duration
   from songs
   where artist = $1
-  order by album, track_number, title
+  order by album, track_number, title, id
   `, artist)
 
 	return

+ 77 - 7
gmus-backend/pkg/server/fetch.go

@@ -10,6 +10,7 @@ import (
 	"github.com/felamaslen/gmus-backend/pkg/read"
 	"github.com/felamaslen/gmus-backend/pkg/repository"
 	"github.com/go-redis/redis/v7"
+	"github.com/jmoiron/sqlx"
 )
 
 type ArtistsResponse struct {
@@ -88,21 +89,26 @@ func routeFetchSongs(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter,
 	return nil
 }
 
-func routeFetchSongInfo(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
+func validateSongId(w http.ResponseWriter, r *http.Request) (id int, err error) {
 	idRaw := r.URL.Query().Get("id")
-	idInt, err := strconv.Atoi(idRaw)
+	id, err = strconv.Atoi(idRaw)
 	if err != nil {
 		http.Error(w, "Must provide a valid id", http.StatusBadRequest)
-		return nil
-	}
-	if idInt < 1 {
+	} else if id < 1 {
 		http.Error(w, "id must be non-negative", http.StatusBadRequest)
+	}
+	return
+}
+
+func routeFetchSongInfo(l *logger.Logger, rdb *redis.Client, w http.ResponseWriter, r *http.Request) error {
+	id, err := validateSongId(w, r)
+	if err != nil {
 		return nil
 	}
 
 	db := database.GetConnection()
 
-	song, err := repository.SelectSong(db, idInt)
+	song, err := repository.SelectSong(db, id)
 	if err != nil {
 		if err.Error() == "No such ID" {
 			http.Error(w, "Song not found", http.StatusNotFound)
@@ -112,7 +118,7 @@ func routeFetchSongInfo(l *logger.Logger, rdb *redis.Client, w http.ResponseWrit
 	}
 
 	response, err := json.Marshal(read.SongExternal{
-		Id:          idInt,
+		Id:          id,
 		TrackNumber: song.TrackNumber,
 		Title:       song.Title,
 		Artist:      song.Artist,
@@ -126,3 +132,67 @@ func routeFetchSongInfo(l *logger.Logger, rdb *redis.Client, w http.ResponseWrit
 	w.Write(response)
 	return nil
 }
+
+type NullResponse struct {
+	Id int `json:"id"`
+}
+
+func respondWithSongOrNull(db *sqlx.DB, w http.ResponseWriter, song *read.Song) error {
+	if song.Id == 0 {
+		response, _ := json.Marshal(NullResponse{})
+		w.Write(response)
+		return nil
+	}
+
+	response, err := json.Marshal(read.SongExternal{
+		Id:          song.Id,
+		TrackNumber: song.TrackNumber,
+		Title:       song.Title,
+		Artist:      song.Artist,
+		Album:       song.Album,
+		Duration:    song.Duration,
+	})
+
+	if err != nil {
+		return err
+	}
+
+	w.Write(response)
+	return nil
+}
+
+func routeFetchNextSong(l *logger.Logger, rdb *redis.Client, 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))
+	if err != nil {
+		return err
+	}
+
+	if err := respondWithSongOrNull(db, w, nextSong); err != nil {
+		return err
+	}
+	return nil
+}
+
+func routeFetchPrevSong(l *logger.Logger, rdb *redis.Client, 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))
+	if err != nil {
+		return err
+	}
+
+	if err := respondWithSongOrNull(db, w, prevSong); err != nil {
+		return err
+	}
+	return nil
+}

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

@@ -31,10 +31,13 @@ func StartServer() {
 
 	router.Path("/song-info").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchSongInfo))
 
+	router.Path("/next-song").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchNextSong))
+	router.Path("/prev-song").Methods("GET").HandlerFunc(routeHandler(l, rdb, routeFetchPrevSong))
+
 	port := conf.Port
 
 	handler := cors.AllowAll().Handler(router)
 
-	l.Info("Starting server on port %d\n", port)
-	log.Fatal(http.ListenAndServe(fmt.Sprintf("localhost:%d", port), handler))
+	l.Info("Starting server on %s:%d\n", conf.Host, port)
+	log.Fatal(http.ListenAndServe(fmt.Sprintf("%s:%d", conf.Host, port), handler))
 }

+ 1 - 1
gmus-web/.env.example

@@ -1 +1 @@
-REACT_APP_API_URL=http://localhost:3000
+REACT_APP_API_URL=http://localhost:3002

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

@@ -1,3 +1,4 @@
+import { Song } from '../types';
 import { Member, MusicPlayer } from '../types/state';
 import { ActionErrorOccurred } from './error';
 import { ActionLocal, ActionRemote, ActionTypeLocal, ActionTypeRemote } from './types';
@@ -51,6 +52,16 @@ export const playPaused = (): ActionPlayPaused => ({
   payload: undefined,
 });
 
+export type ActionSongInfoFetched = ActionLocal<
+  ActionTypeLocal.SongInfoFetched,
+  { song: Song | null; replace: boolean }
+>;
+
+export const songInfoFetched = (song: Song | null, replace = false): ActionSongInfoFetched => ({
+  type: ActionTypeLocal.SongInfoFetched,
+  payload: { song, replace },
+});
+
 export type LocalAction =
   | LoggedOut
   | ActionErrorOccurred
@@ -58,6 +69,7 @@ export type LocalAction =
   | ActionStateSetLocal
   | ActionSeeked
   | ActionPlayPaused
-  | ActionMasterSet;
+  | ActionMasterSet
+  | ActionSongInfoFetched;
 
 export type AnyAction = LocalAction | RemoteAction;

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

@@ -13,6 +13,7 @@ export enum ActionTypeLocal {
   Seeked = '@@local/SEEKED',
   MasterSet = '@@local/MASTER_SET',
   PlayPaused = '@@local/PLAY_PAUSED',
+  SongInfoFetched = '@@local/SONG_INFO_FETCHED',
 }
 
 export interface Action<T extends string = string, P = unknown> {

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

@@ -1,12 +1,12 @@
-import React, { Dispatch, Suspense, useCallback } from 'react';
+import React, { Suspense, useCallback, useContext } from 'react';
 import { StateInspector } from 'reinspect';
 
-import { LocalAction, stateSet } from '../actions';
+import { stateSet } from '../actions';
 import { DispatchContext, StateContext } from '../context/state';
 import { useMaster } from '../hooks/master';
+import { usePlayQueue } from '../hooks/queue';
 import { useKeepalive } from '../hooks/socket';
 import { useCurrentlyPlayingSongInfo } from '../hooks/status';
-import { GlobalState } from '../reducer';
 import { isMaster } from '../selectors';
 import { getSongUrl } from '../utils/url';
 import { LoadingWrapper } from './identify';
@@ -16,17 +16,18 @@ import { UIProvider } from './ui/types';
 
 export type Props = {
   socket: WebSocket;
-  state: GlobalState;
-  dispatch: Dispatch<LocalAction>;
 };
 
 const uiProvider = UIProvider.Cmus;
 const UI = uiProviders[uiProvider];
 
-export const App: React.FC<Props> = ({ socket, state, dispatch }) => {
+export const App: React.FC<Props> = ({ socket }) => {
   useKeepalive(socket);
-  useMaster(state, dispatch);
-  const currentSong = useCurrentlyPlayingSongInfo(state.player.songId);
+  useMaster();
+  useCurrentlyPlayingSongInfo();
+
+  const state = useContext(StateContext);
+  const dispatch = useContext(DispatchContext);
 
   const onTimeUpdate = useCallback(
     (currentTime: number): void => {
@@ -35,6 +36,27 @@ export const App: React.FC<Props> = ({ socket, state, dispatch }) => {
     [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]);
+
   return (
     <>
       {isMaster(state) && !!state.player.songId && (
@@ -44,17 +66,19 @@ export const App: React.FC<Props> = ({ socket, state, dispatch }) => {
           seekTime={state.player.seekTime}
           onTimeUpdate={onTimeUpdate}
           timeUpdateFPS={1}
+          onEnded={onEnded}
         />
       )}
-      <StateContext.Provider value={state}>
-        <DispatchContext.Provider value={dispatch}>
-          <StateInspector name="ui">
-            <Suspense fallback={<LoadingWrapper />}>
-              <UI isMaster={isMaster(state)} currentSong={currentSong} />
-            </Suspense>
-          </StateInspector>
-        </DispatchContext.Provider>
-      </StateContext.Provider>
+      <StateInspector name="ui">
+        <Suspense fallback={<LoadingWrapper />}>
+          <UI
+            isMaster={isMaster(state)}
+            currentSong={state.songInfo}
+            nextSong={nextSong}
+            prevSong={prevSong}
+          />
+        </Suspense>
+      </StateInspector>
     </>
   );
 };

+ 3 - 0
gmus-web/src/components/player.tsx

@@ -7,6 +7,7 @@ type Props = {
   playing: boolean;
   seekTime: number;
   onTimeUpdate: (time: number) => void;
+  onEnded: () => void;
   timeUpdateFPS: number;
 };
 
@@ -41,6 +42,7 @@ export const Player: React.FC<Props> = ({
   playing,
   seekTime,
   onTimeUpdate: reportTimeUpdate,
+  onEnded,
   timeUpdateFPS,
 }) => {
   const audio = useRef<HTMLAudioElement | null>(null);
@@ -136,6 +138,7 @@ export const Player: React.FC<Props> = ({
       onProgress={onProgress}
       onTimeUpdate={onTimeUpdateThrottled}
       onWaiting={onWaiting}
+      onEnded={onEnded}
       ref={audio}
       src={src}
     />

+ 8 - 1
gmus-web/src/components/root.tsx

@@ -1,6 +1,7 @@
 import React, { Reducer, useCallback, useReducer } from 'react';
 
 import { AnyAction, nameSet } from '../actions';
+import { DispatchContext, StateContext } from '../context/state';
 import { useDispatchWithEffects, useOnMessage, useSocket } from '../hooks/socket';
 import { globalReducer, GlobalState, initialState } from '../reducer';
 import { init } from '../utils/state';
@@ -31,5 +32,11 @@ export const Root: React.FC = () => {
     return <Identify connecting={connecting} onIdentify={onIdentify} />;
   }
 
-  return <App socket={socket} state={state} dispatch={dispatchWithEffects} />;
+  return (
+    <StateContext.Provider value={state}>
+      <DispatchContext.Provider value={dispatchWithEffects}>
+        <App socket={socket} />
+      </DispatchContext.Provider>
+    </StateContext.Provider>
+  );
 };

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

@@ -1,731 +0,0 @@
-import { loggedOut, masterSet, playPaused, stateSet } from '../../../actions';
-import { ActionKeyPressed, ActionTypeKeyPressed, Keys } from '../../../hooks/vim';
-import { Song } from '../../../types';
-import {
-  artistAlbumsLoaded,
-  artistSongsLoaded,
-  artistsSet,
-  clientActivated,
-  CmusUIActionType,
-  commandSet,
-} from './actions';
-import { cmusUIReducer, initialCmusUIState } from './reducer';
-import { CmusUIState, LibraryModeWindow, Overlay, View } from './types';
-
-describe(cmusUIReducer.name, () => {
-  const stateLibrary: CmusUIState = {
-    ...initialCmusUIState,
-    view: View.Library,
-  };
-
-  const stateCommandMode: CmusUIState = {
-    ...stateLibrary,
-    commandMode: true,
-  };
-
-  const stateDifferentView: CmusUIState = {
-    ...initialCmusUIState,
-    view: (undefined as unknown) as View,
-    scroll: { delta: 0, serialNumber: 8813 },
-  };
-
-  describe(CmusUIActionType.ArtistsSet, () => {
-    const action = artistsSet(['Artist A', 'Artist B']);
-
-    it('should set the artists array', () => {
-      expect.assertions(1);
-      const result = cmusUIReducer(initialCmusUIState, action);
-      expect(result.artists).toStrictEqual(['Artist A', 'Artist B']);
-    });
-
-    it('should set the active artist to the first artist in the list', () => {
-      expect.assertions(2);
-      const result = cmusUIReducer(
-        {
-          ...initialCmusUIState,
-          library: {
-            ...initialCmusUIState.library,
-            activeArtist: 'Artist Z',
-            activeAlbum: 'Some album',
-          },
-        },
-        action,
-      );
-      expect(result.library.activeArtist).toBe('Artist A');
-      expect(result.library.activeAlbum).toBeNull();
-    });
-  });
-
-  describe(CmusUIActionType.ArtistAlbumsLoaded, () => {
-    const action = artistAlbumsLoaded('My artist', ['Album A', 'Album B']);
-
-    it('should set the albums for the given artist', () => {
-      expect.assertions(1);
-      const result = cmusUIReducer(initialCmusUIState, action);
-      expect(result.artistAlbums).toStrictEqual(
-        expect.objectContaining({
-          'My artist': ['Album A', 'Album B'],
-        }),
-      );
-    });
-  });
-
-  describe(CmusUIActionType.ArtistSongsLoaded, () => {
-    const action = artistSongsLoaded('My artist', [
-      { id: 12, track: 23, title: 'Title A', artist: 'My artist', album: 'Album A', time: 123 },
-      { id: 73, track: 17, title: 'Title B', artist: 'My artist', album: 'Album B', time: 456 },
-    ]);
-
-    it('should set the songs for the given artist', () => {
-      expect.assertions(1);
-      const result = cmusUIReducer(initialCmusUIState, action);
-      expect(result.artistSongs).toStrictEqual(
-        expect.objectContaining({
-          'My artist': [
-            {
-              id: 12,
-              track: 23,
-              title: 'Title A',
-              artist: 'My artist',
-              album: 'Album A',
-              time: 123,
-            },
-            {
-              id: 73,
-              track: 17,
-              title: 'Title B',
-              artist: 'My artist',
-              album: 'Album B',
-              time: 456,
-            },
-          ],
-        }),
-      );
-    });
-
-    describe('when the artist is the active artist', () => {
-      const stateWithActiveArtist: CmusUIState = {
-        ...initialCmusUIState,
-        library: {
-          ...initialCmusUIState.library,
-          activeArtist: 'My artist',
-        },
-      };
-
-      it('should set the active song ID to the first song ID in the list', () => {
-        expect.assertions(1);
-        const result = cmusUIReducer(stateWithActiveArtist, action);
-        expect(result.library.activeSongId).toBe(12);
-      });
-    });
-  });
-
-  describe(CmusUIActionType.CommandSet, () => {
-    describe('q', () => {
-      const action = commandSet('q');
-
-      it('should set a log out global action', () => {
-        expect.assertions(2);
-        const result = cmusUIReducer(stateCommandMode, action);
-        expect(result.commandMode).toBe(false);
-        expect(result.globalAction).toStrictEqual(loggedOut());
-      });
-    });
-  });
-
-  describe(CmusUIActionType.ClientActivated, () => {
-    const action = clientActivated('some-client');
-
-    it('should set the active client', () => {
-      expect.assertions(1);
-      const result = cmusUIReducer(initialCmusUIState, action);
-      expect(result.clientList.active).toBe('some-client');
-    });
-  });
-
-  describe('Keypress actions', () => {
-    describe.each`
-      key          | toView
-      ${Keys['1']} | ${View.Library}
-      ${Keys['2']} | ${View.ClientList}
-    `('$key', ({ key, toView }) => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key };
-
-      it(`should set the view to ${toView}`, () => {
-        expect.assertions(1);
-        const state = ({ ...initialCmusUIState, view: undefined } as unknown) as CmusUIState;
-        const result = cmusUIReducer(state, action);
-
-        expect(result.view).toBe(toView);
-      });
-    });
-
-    describe(Keys.tab, () => {
-      describe('when in library view', () => {
-        describe.each`
-          fromModeWindow                  | toModeWindow
-          ${LibraryModeWindow.ArtistList} | ${LibraryModeWindow.SongList}
-          ${LibraryModeWindow.SongList}   | ${LibraryModeWindow.ArtistList}
-        `('when the mode window is $fromModeWindow', ({ fromModeWindow, toModeWindow }) => {
-          const stateFromMode: CmusUIState = {
-            ...stateLibrary,
-            library: {
-              ...stateLibrary.library,
-              modeWindow: fromModeWindow,
-            },
-          };
-
-          it(`should set the mode window to ${toModeWindow}`, () => {
-            expect.assertions(1);
-            const result = cmusUIReducer(stateFromMode, {
-              type: ActionTypeKeyPressed,
-              key: Keys.tab,
-            });
-
-            expect(result.library.modeWindow).toBe(toModeWindow);
-          });
-        });
-      });
-    });
-
-    describe(Keys.C, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.C };
-
-      it('should set the globalAction to play/pause', () => {
-        expect.assertions(2);
-        const result = cmusUIReducer(stateLibrary, action);
-        expect(result.globalAction).toStrictEqual(playPaused());
-        expect(result.globalActionSerialNumber).toBe(stateLibrary.globalActionSerialNumber + 1);
-      });
-    });
-
-    describe(Keys.J, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.J };
-
-      describe('when in library view', () => {
-        describe('when in the artist list mode', () => {
-          const stateArtistMode: CmusUIState = {
-            ...stateLibrary,
-            artists: ['Artist A', 'Artist B'],
-            artistAlbums: {
-              'Artist A': ['Album 1', 'Album 2'],
-            },
-            artistSongs: {
-              'Artist A': [
-                { id: 456, album: 'Album 2' } as Song,
-                { id: 123, album: 'Album 1' } as Song,
-              ],
-            },
-            library: {
-              ...stateLibrary.library,
-              activeArtist: 'Artist A',
-              modeWindow: LibraryModeWindow.ArtistList,
-            },
-          };
-
-          it('should set the active artist to the next available artist', () => {
-            expect.assertions(1);
-            const result = cmusUIReducer(stateArtistMode, action);
-
-            expect(result.library.activeArtist).toBe('Artist B');
-          });
-
-          it('should set the active song ID to the first by the artist', () => {
-            expect.assertions(1);
-            const state: CmusUIState = {
-              ...stateArtistMode,
-              artistSongs: {
-                'Artist B': [{ id: 123 } as Song, { id: 456 } as Song],
-              },
-            };
-            const result = cmusUIReducer(state, action);
-
-            expect(result.library.activeSongId).toBe(123);
-          });
-
-          describe('when there are no songs loaded for the artist', () => {
-            it('should set the active song ID to null', () => {
-              expect.assertions(1);
-              const state: CmusUIState = {
-                ...stateArtistMode,
-                artistSongs: {},
-              };
-              const result = cmusUIReducer(state, action);
-
-              expect(result.library.activeSongId).toBeNull();
-            });
-          });
-
-          describe('when the current artist is expanded', () => {
-            const stateArtistModeWithAlbums: CmusUIState = {
-              ...stateArtistMode,
-              library: {
-                ...stateArtistMode.library,
-                expandedArtists: ['Artist A'],
-              },
-            };
-
-            it('should select the next album', () => {
-              expect.assertions(2);
-              const result = cmusUIReducer(stateArtistModeWithAlbums, action);
-
-              expect(result.library.activeArtist).toBe('Artist A');
-              expect(result.library.activeAlbum).toBe('Album 1');
-            });
-
-            it('should set the active song ID to the first matching the album', () => {
-              expect.assertions(1);
-              const result = cmusUIReducer(stateArtistModeWithAlbums, action);
-
-              expect(result.library.activeSongId).toBe(123);
-            });
-          });
-        });
-
-        describe('when in the song list mode', () => {
-          const stateSongsMode: CmusUIState = {
-            ...stateLibrary,
-            artists: ['Artist A'],
-            artistSongs: {
-              'Artist A': [{ id: 123 } as Song, { id: 456 } as Song, { id: 789 } as Song],
-            },
-            library: {
-              ...stateLibrary.library,
-              activeArtist: 'Artist A',
-              activeSongId: 123,
-              modeWindow: LibraryModeWindow.SongList,
-            },
-          };
-
-          it('should set the active song ID to the next available song', () => {
-            expect.assertions(1);
-            const result = cmusUIReducer(stateSongsMode, action);
-
-            expect(result.library.activeSongId).toBe(456);
-          });
-        });
-      });
-
-      describe('when in a different view', () => {
-        it('should set the scroll delta and increment the serial number', () => {
-          expect.assertions(1);
-          const result = cmusUIReducer(stateDifferentView, action);
-          expect(result.scroll).toStrictEqual({ delta: 1, serialNumber: 8814 });
-        });
-      });
-    });
-
-    describe(Keys.K, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.K };
-
-      describe('when in library view', () => {
-        describe('when in the artist list mode', () => {
-          const stateArtistMode: CmusUIState = {
-            ...stateLibrary,
-            artists: ['Artist A', 'Artist B'],
-            artistAlbums: {
-              'Artist B': ['Album 1', 'Album 2'],
-            },
-            artistSongs: {
-              'Artist B': [
-                { id: 456, album: 'Album 2' } as Song,
-                { id: 123, album: 'Album 1' } as Song,
-              ],
-            },
-            library: {
-              ...stateLibrary.library,
-              activeArtist: 'Artist B',
-              modeWindow: LibraryModeWindow.ArtistList,
-            },
-          };
-
-          it('should set the active artist to the previous available artist', () => {
-            expect.assertions(1);
-            const result = cmusUIReducer(stateArtistMode, action);
-
-            expect(result.library.activeArtist).toBe('Artist A');
-          });
-
-          it('should set the active song ID to the first by the artist', () => {
-            expect.assertions(1);
-            const state: CmusUIState = {
-              ...stateArtistMode,
-              artistSongs: {
-                'Artist A': [{ id: 123 } as Song, { id: 456 } as Song],
-              },
-            };
-            const result = cmusUIReducer(state, action);
-
-            expect(result.library.activeSongId).toBe(123);
-          });
-
-          describe('when there are no songs loaded for the artist', () => {
-            it('should set the active song ID to null', () => {
-              expect.assertions(1);
-              const state: CmusUIState = {
-                ...stateArtistMode,
-                artistSongs: {},
-              };
-              const result = cmusUIReducer(state, action);
-
-              expect(result.library.activeSongId).toBeNull();
-            });
-          });
-
-          describe('when the current artist is expanded', () => {
-            const stateArtistModeWithAlbums: CmusUIState = {
-              ...stateArtistMode,
-              library: {
-                ...stateArtistMode.library,
-                expandedArtists: ['Artist B'],
-                activeAlbum: 'Album 2',
-              },
-            };
-
-            it('should select the previous album', () => {
-              expect.assertions(2);
-              const result = cmusUIReducer(stateArtistModeWithAlbums, action);
-
-              expect(result.library.activeArtist).toBe('Artist B');
-              expect(result.library.activeAlbum).toBe('Album 1');
-            });
-
-            it('should set the active song ID to the first matching the album', () => {
-              expect.assertions(1);
-              const result = cmusUIReducer(stateArtistModeWithAlbums, action);
-
-              expect(result.library.activeSongId).toBe(123);
-            });
-          });
-        });
-
-        describe('when in the song list mode', () => {
-          const stateSongsMode: CmusUIState = {
-            ...stateLibrary,
-            artists: ['Artist A'],
-            artistSongs: {
-              'Artist A': [{ id: 123 } as Song, { id: 456 } as Song, { id: 789 } as Song],
-            },
-            library: {
-              ...stateLibrary.library,
-              activeArtist: 'Artist A',
-              activeSongId: 456,
-              modeWindow: LibraryModeWindow.SongList,
-            },
-          };
-
-          it('should set the active song ID to the previous available song', () => {
-            expect.assertions(1);
-            const result = cmusUIReducer(stateSongsMode, action);
-
-            expect(result.library.activeSongId).toBe(123);
-          });
-        });
-      });
-
-      describe('when in a different view', () => {
-        it('should set the scroll delta and increment the serial number', () => {
-          expect.assertions(1);
-          const result = cmusUIReducer(stateDifferentView, action);
-          expect(result.scroll).toStrictEqual({ delta: -1, serialNumber: 8814 });
-        });
-      });
-    });
-
-    describe(Keys.pageDown, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.pageDown };
-
-      describe('when in library view', () => {
-        describe('when in the artist list mode', () => {
-          const stateArtistMode: CmusUIState = {
-            ...stateLibrary,
-            artists: Array(26)
-              .fill(0)
-              .map((_, index) => `Artist ${index + 1}`),
-            artistAlbums: {
-              'Artist 3': ['Album 1', 'Album 2'],
-              'Artist 4': ['Album Z'],
-              'Artist 18': ['Album 3'],
-            },
-            artistSongs: {
-              'Artist 18': [{ id: 123, album: 'Album 3' } as Song],
-            },
-            library: {
-              ...stateLibrary.library,
-              activeArtist: 'Artist 1',
-              activeAlbum: null,
-              expandedArtists: ['Artist 3', 'Artist 18'],
-              modeWindow: LibraryModeWindow.ArtistList,
-            },
-          };
-
-          it('should page the active artist and album by 20 rows down', () => {
-            expect.assertions(2);
-            const result = cmusUIReducer(stateArtistMode, action);
-
-            expect(result.library.activeArtist).toBe('Artist 18');
-            expect(result.library.activeAlbum).toBe('Album 3');
-          });
-
-          it('should set the active song ID to the first by the artist', () => {
-            expect.assertions(1);
-            const result = cmusUIReducer(stateArtistMode, action);
-
-            expect(result.library.activeSongId).toBe(123);
-          });
-        });
-
-        describe('when in the song list mode', () => {
-          const stateSongsMode: CmusUIState = {
-            ...stateLibrary,
-            artists: ['Artist A'],
-            artistSongs: {
-              'Artist A': Array(30)
-                .fill(0)
-                .map((_, index) => ({ id: index + 100 } as Song)),
-            },
-            library: {
-              ...stateLibrary.library,
-              activeArtist: 'Artist A',
-              activeSongId: 101,
-              modeWindow: LibraryModeWindow.SongList,
-            },
-          };
-
-          it('should set the active song ID to the one 20th after current', () => {
-            expect.assertions(1);
-            const result = cmusUIReducer(stateSongsMode, action);
-
-            expect(result.library.activeSongId).toBe(121);
-          });
-        });
-      });
-    });
-
-    describe(Keys.pageUp, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.pageUp };
-
-      describe('when in library view', () => {
-        describe('when in the artist list mode', () => {
-          const stateArtistMode: CmusUIState = {
-            ...stateLibrary,
-            artists: Array(26)
-              .fill(0)
-              .map((_, index) => `Artist ${index + 1}`),
-            artistAlbums: {
-              'Artist 3': ['Album 1', 'Album 2'],
-              'Artist 4': ['Album X', 'Album Y', 'Album Z'],
-              'Artist 18': ['Album 3'],
-            },
-            artistSongs: {
-              'Artist 3': [{ id: 123, album: 'Album 1' } as Song],
-            },
-            library: {
-              ...stateLibrary.library,
-              activeArtist: 'Artist 18',
-              activeAlbum: 'Album 3',
-              expandedArtists: ['Artist 3', 'Artist 4', 'Artist 18'],
-              modeWindow: LibraryModeWindow.ArtistList,
-            },
-          };
-
-          it('should page the active artist and album by 20 rows down', () => {
-            expect.assertions(2);
-            const result = cmusUIReducer(stateArtistMode, action);
-
-            expect(result.library.activeArtist).toBe('Artist 3');
-            expect(result.library.activeAlbum).toBe('Album 1');
-          });
-
-          it('should set the active song ID to the first by the artist', () => {
-            expect.assertions(1);
-            const result = cmusUIReducer(stateArtistMode, action);
-
-            expect(result.library.activeSongId).toBe(123);
-          });
-        });
-
-        describe('when in the song list mode', () => {
-          const stateSongsMode: CmusUIState = {
-            ...stateLibrary,
-            artists: ['Artist A'],
-            artistSongs: {
-              'Artist A': Array(30)
-                .fill(0)
-                .map((_, index) => ({ id: index + 100 } as Song)),
-            },
-            library: {
-              ...stateLibrary.library,
-              activeArtist: 'Artist A',
-              activeSongId: 128,
-              modeWindow: LibraryModeWindow.SongList,
-            },
-          };
-
-          it('should set the active song ID to the one 20th prior to current', () => {
-            expect.assertions(1);
-            const result = cmusUIReducer(stateSongsMode, action);
-
-            expect(result.library.activeSongId).toBe(108);
-          });
-        });
-      });
-    });
-
-    describe(Keys.space, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.space };
-
-      describe('when in library view', () => {
-        describe('when in the artist list mode', () => {
-          it('should toggle the active artist', () => {
-            expect.assertions(2);
-
-            const state: CmusUIState = {
-              ...initialCmusUIState,
-              library: {
-                ...initialCmusUIState.library,
-                expandedArtists: [],
-                activeArtist: 'Some artist',
-              },
-            };
-
-            const firstResult = cmusUIReducer(state, action);
-            expect(firstResult.library.expandedArtists).toStrictEqual(['Some artist']);
-
-            const secondResult = cmusUIReducer(firstResult, action);
-            expect(secondResult.library.expandedArtists).toStrictEqual([]);
-          });
-
-          describe('when there is no active artist', () => {
-            it('should return the state', () => {
-              expect.assertions(1);
-              const stateNoActive: CmusUIState = {
-                ...initialCmusUIState,
-                library: {
-                  ...initialCmusUIState.library,
-                  activeArtist: null,
-                },
-              };
-
-              const result = cmusUIReducer(stateNoActive, action);
-              expect(result).toStrictEqual(stateNoActive);
-            });
-          });
-
-          describe('when the active album will disappear', () => {
-            const stateWithActiveAlbum: CmusUIState = {
-              ...initialCmusUIState,
-              artistAlbums: {
-                'Artist A': ['Album A', 'Album B', 'Album C'],
-              },
-              library: {
-                ...initialCmusUIState.library,
-                activeArtist: 'Artist A',
-                expandedArtists: ['Artist A'],
-                activeAlbum: 'Album B',
-              },
-            };
-
-            it('should set the active album to null', () => {
-              expect.assertions(2);
-              const result = cmusUIReducer(stateWithActiveAlbum, action);
-              expect(result.library.activeArtist).toBe('Artist A');
-              expect(result.library.activeAlbum).toBeNull();
-            });
-          });
-        });
-      });
-    });
-
-    describe(Keys.enter, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.enter };
-
-      describe('when in library view', () => {
-        describe('when in the songs list mode', () => {
-          const state: CmusUIState = {
-            ...initialCmusUIState,
-            globalActionSerialNumber: 1875,
-            library: {
-              ...initialCmusUIState.library,
-              modeWindow: LibraryModeWindow.SongList,
-              activeSongId: 713,
-            },
-          };
-
-          it('should set the globalAction to play the active song and increment the serial', () => {
-            expect.assertions(2);
-
-            const result = cmusUIReducer(state, action);
-
-            expect(result.globalAction).toStrictEqual(
-              stateSet({
-                playing: true,
-                songId: 713,
-                currentTime: 0,
-                seekTime: 0,
-              }),
-            );
-            expect(result.globalActionSerialNumber).toBe(1876);
-          });
-        });
-      });
-
-      describe('when in client list view', () => {
-        const state: CmusUIState = {
-          ...initialCmusUIState,
-          globalActionSerialNumber: 123,
-          view: View.ClientList,
-          clientList: {
-            active: 'some-active-client',
-          },
-        };
-
-        it('should set the globalAction to set the given client to master', () => {
-          expect.assertions(1);
-          const result = cmusUIReducer(state, action);
-          expect(result.globalAction).toStrictEqual(masterSet('some-active-client'));
-        });
-      });
-    });
-
-    describe(Keys.esc, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.esc };
-
-      describe.each`
-        overlay
-        ${Overlay.Help}
-      `('when the overlay is set to $overlay', ({ overlay }) => {
-        const stateWithOverlay: CmusUIState = {
-          ...initialCmusUIState,
-          overlay,
-        };
-
-        it('should reset the overlay', () => {
-          expect.assertions(1);
-          const result = cmusUIReducer(stateWithOverlay, action);
-          expect(result.overlay).toBeNull();
-        });
-      });
-    });
-
-    describe(Keys.colon, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.colon };
-
-      it('should enter command mode', () => {
-        expect.assertions(1);
-        const result = cmusUIReducer(stateLibrary, action);
-        expect(result.commandMode).toBe(true);
-      });
-    });
-
-    describe(Keys.question, () => {
-      const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.question };
-
-      it('should set the overlay to help mode', () => {
-        expect.assertions(1);
-        const result = cmusUIReducer(initialCmusUIState, action);
-        expect(result.overlay).toBe(Overlay.Help);
-      });
-    });
-  });
-});

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

@@ -1,294 +0,0 @@
-import { createContext, Dispatch } from 'react';
-
-import { LocalAction, loggedOut, masterSet, playPaused, stateSet } from '../../../actions';
-import { nullDispatch } from '../../../context/state';
-import { ActionTypeKeyPressed, Keys } from '../../../hooks/vim';
-import { Song } from '../../../types';
-import { scrollThroughItems } from '../../../utils/delta';
-import {
-  ArtistAlbumsLoaded,
-  ArtistSongsLoaded,
-  ArtistsSet,
-  CmusUIAction,
-  CmusUIActionType,
-} from './actions';
-import { CmusUIState, LibraryModeWindow, Overlay, View } from './types';
-import { getNextActiveArtistAndAlbum } from './utils/scroll';
-
-export const initialCmusUIState: CmusUIState = {
-  globalAction: null,
-  globalActionSerialNumber: 0,
-  scroll: { delta: 0, serialNumber: 0 },
-  view: View.Library,
-  commandMode: false,
-  overlay: null,
-  artists: [],
-  artistAlbums: {},
-  artistSongs: {},
-  library: {
-    modeWindow: LibraryModeWindow.ArtistList,
-    expandedArtists: [],
-    activeArtist: null,
-    activeAlbum: null,
-    activeSongId: null,
-    visibleSongs: [],
-  },
-  clientList: {
-    active: null,
-  },
-};
-
-export const CmusUIStateContext = createContext<CmusUIState>(initialCmusUIState);
-export const CmusUIDispatchContext = createContext<Dispatch<CmusUIAction>>(nullDispatch);
-
-const libraryModeWindows: LibraryModeWindow[] = Object.values(LibraryModeWindow);
-
-const withGlobalAction = (state: CmusUIState, action: LocalAction): CmusUIState => ({
-  ...state,
-  globalAction: action,
-  globalActionSerialNumber: state.globalActionSerialNumber + 1,
-});
-
-const switchLibraryMode = (state: CmusUIState): CmusUIState => ({
-  ...state,
-  library: {
-    ...state.library,
-    modeWindow:
-      libraryModeWindows[
-        (libraryModeWindows.indexOf(state.library.modeWindow) + 1) % libraryModeWindows.length
-      ],
-  },
-});
-
-function getActiveSongIdFromActiveArtistAlbum(
-  activeArtist: string | null,
-  activeAlbum: string | null,
-  artistSongs: Record<string, Song[]>,
-): number | null {
-  if (activeArtist === null) {
-    return null;
-  }
-  const songs = artistSongs[activeArtist] ?? [];
-  if (!activeAlbum) {
-    return songs[0]?.id ?? null;
-  }
-  return songs.find((compare) => compare.album === activeAlbum)?.id ?? null;
-}
-
-const scrollArtists = (state: CmusUIState, delta: number): CmusUIState => {
-  const { artist, album } = getNextActiveArtistAndAlbum(
-    state.artists,
-    state.artistAlbums,
-    state.library.activeArtist,
-    state.library.activeAlbum,
-    state.library.expandedArtists,
-    delta,
-  );
-
-  return {
-    ...state,
-    library: {
-      ...state.library,
-      activeArtist: artist,
-      activeAlbum: album,
-      activeSongId: getActiveSongIdFromActiveArtistAlbum(artist, album, state.artistSongs),
-    },
-  };
-};
-
-const scrollSongs = (state: CmusUIState, delta: number): CmusUIState =>
-  state.library.activeArtist === null
-    ? state
-    : {
-        ...state,
-        library: {
-          ...state.library,
-          activeSongId: scrollThroughItems(
-            state.artistSongs[state.library.activeArtist] ?? [],
-            (compare) => compare.id === state.library.activeSongId,
-            delta,
-          ).id,
-        },
-      };
-
-function toggleExpandArtist(library: CmusUIState['library']): CmusUIState['library'] {
-  if (library.activeArtist === null) {
-    return library;
-  }
-  if (library.expandedArtists.includes(library.activeArtist)) {
-    return {
-      ...library,
-      expandedArtists: library.expandedArtists.filter(
-        (compare) => compare !== library.activeArtist,
-      ),
-      activeAlbum: null,
-    };
-  }
-  return { ...library, expandedArtists: [...library.expandedArtists, library.activeArtist] };
-}
-
-function handleScrollLibrary(state: CmusUIState, delta: number): CmusUIState {
-  switch (state.library.modeWindow) {
-    case LibraryModeWindow.ArtistList:
-      return scrollArtists(state, delta);
-    case LibraryModeWindow.SongList:
-      return scrollSongs(state, delta);
-    default:
-      return state;
-  }
-}
-
-function handleScroll(state: CmusUIState, delta: number): CmusUIState {
-  switch (state.view) {
-    case View.Library:
-      return handleScrollLibrary(state, delta);
-    default:
-      return {
-        ...state,
-        scroll: { delta, serialNumber: state.scroll.serialNumber + 1 },
-      };
-  }
-}
-
-function handleActivate(state: CmusUIState): CmusUIState {
-  switch (state.view) {
-    case View.Library:
-      if (state.library.modeWindow === LibraryModeWindow.SongList) {
-        if (!state.library.activeSongId) {
-          return state;
-        }
-
-        return withGlobalAction(
-          state,
-          stateSet({
-            playing: true,
-            songId: state.library.activeSongId,
-            currentTime: 0,
-            seekTime: 0,
-          }),
-        );
-      }
-      return state;
-
-    case View.ClientList:
-      if (!state.clientList.active) {
-        return state;
-      }
-      return withGlobalAction(state, masterSet(state.clientList.active));
-
-    default:
-      return state;
-  }
-}
-
-function handleKeyPress(state: CmusUIState, key: string): CmusUIState {
-  switch (key) {
-    case Keys.colon:
-      return { ...state, commandMode: true };
-
-    case Keys['1']:
-      return { ...state, view: View.Library };
-    case Keys['2']:
-      return { ...state, view: View.ClientList };
-
-    case Keys.tab:
-      if (state.view === View.Library) {
-        return switchLibraryMode(state);
-      }
-      return state;
-
-    case Keys.space:
-      if (state.view === View.Library) {
-        if (state.library.modeWindow === LibraryModeWindow.ArtistList) {
-          return { ...state, library: toggleExpandArtist(state.library) };
-        }
-      }
-      return state;
-
-    case Keys.enter:
-      return handleActivate(state);
-
-    case Keys.esc:
-      return { ...state, overlay: null };
-
-    case Keys.question:
-      return { ...state, overlay: Overlay.Help };
-
-    case Keys.C:
-      return withGlobalAction(state, playPaused());
-
-    case Keys.J:
-      return handleScroll(state, 1);
-    case Keys.K:
-      return handleScroll(state, -1);
-
-    case Keys.pageDown:
-      return handleScroll(state, 20);
-    case Keys.pageUp:
-      return handleScroll(state, -20);
-
-    default:
-      return state;
-  }
-}
-
-const setArtists = (state: CmusUIState, action: ArtistsSet): CmusUIState => ({
-  ...state,
-  artists: action.payload,
-  library: {
-    ...state.library,
-    activeArtist: action.payload[0] ?? null,
-    activeAlbum: null,
-  },
-});
-
-const setArtistAlbums = (state: CmusUIState, action: ArtistAlbumsLoaded): CmusUIState => ({
-  ...state,
-  artistAlbums: { ...state.artistAlbums, [action.payload.artist]: action.payload.albums },
-});
-
-const setArtistSongs = (state: CmusUIState, action: ArtistSongsLoaded): CmusUIState => ({
-  ...state,
-  artistSongs: { ...state.artistSongs, [action.payload.artist]: action.payload.songs },
-  library: {
-    ...state.library,
-    activeSongId:
-      state.library.activeArtist === action.payload.artist
-        ? action.payload.songs[0]?.id ?? null
-        : state.library.activeSongId,
-  },
-});
-
-function onCommand(state: CmusUIState, command: string | null): CmusUIState {
-  const nextState: CmusUIState = { ...state, commandMode: false };
-
-  switch (command) {
-    case 'q':
-      return withGlobalAction(nextState, loggedOut());
-    default:
-      return nextState;
-  }
-}
-
-export function cmusUIReducer(state: CmusUIState, action: CmusUIAction): CmusUIState {
-  switch (action.type) {
-    case ActionTypeKeyPressed:
-      return handleKeyPress(state, action.key);
-
-    case CmusUIActionType.ArtistsSet:
-      return setArtists(state, action);
-    case CmusUIActionType.ArtistAlbumsLoaded:
-      return setArtistAlbums(state, action);
-    case CmusUIActionType.ArtistSongsLoaded:
-      return setArtistSongs(state, action);
-
-    case CmusUIActionType.CommandSet:
-      return onCommand(state, action.payload);
-
-    case CmusUIActionType.ClientActivated:
-      return { ...state, clientList: { active: action.payload } };
-
-    default:
-      return state;
-  }
-}

+ 87 - 0
gmus-web/src/components/ui/cmus/reducer/artists.spec.ts

@@ -0,0 +1,87 @@
+import { artistAlbumsLoaded, artistSongsLoaded, artistsSet, CmusUIActionType } from '../actions';
+
+import { stateWithActiveArtist } from './fixtures';
+import { cmusUIReducer, initialCmusUIState } from './reducer';
+
+describe(CmusUIActionType.ArtistsSet, () => {
+  const action = artistsSet(['Artist A', 'Artist B']);
+
+  it('should set the artists array', () => {
+    expect.assertions(1);
+    const result = cmusUIReducer(initialCmusUIState, action);
+    expect(result.artists).toStrictEqual(['Artist A', 'Artist B']);
+  });
+
+  it('should set the active artist to the first artist in the list', () => {
+    expect.assertions(2);
+    const result = cmusUIReducer(
+      {
+        ...initialCmusUIState,
+        library: {
+          ...initialCmusUIState.library,
+          activeArtist: 'Artist Z',
+          activeAlbum: 'Some album',
+        },
+      },
+      action,
+    );
+    expect(result.library.activeArtist).toBe('Artist A');
+    expect(result.library.activeAlbum).toBeNull();
+  });
+});
+
+describe(CmusUIActionType.ArtistAlbumsLoaded, () => {
+  const action = artistAlbumsLoaded('My artist', ['Album A', 'Album B']);
+
+  it('should set the albums for the given artist', () => {
+    expect.assertions(1);
+    const result = cmusUIReducer(initialCmusUIState, action);
+    expect(result.artistAlbums).toStrictEqual(
+      expect.objectContaining({
+        'My artist': ['Album A', 'Album B'],
+      }),
+    );
+  });
+});
+
+describe(CmusUIActionType.ArtistSongsLoaded, () => {
+  const action = artistSongsLoaded('My artist', [
+    { id: 12, track: 23, title: 'Title A', artist: 'My artist', album: 'Album A', time: 123 },
+    { id: 73, track: 17, title: 'Title B', artist: 'My artist', album: 'Album B', time: 456 },
+  ]);
+
+  it('should set the songs for the given artist', () => {
+    expect.assertions(1);
+    const result = cmusUIReducer(initialCmusUIState, action);
+    expect(result.artistSongs).toStrictEqual(
+      expect.objectContaining({
+        'My artist': [
+          {
+            id: 12,
+            track: 23,
+            title: 'Title A',
+            artist: 'My artist',
+            album: 'Album A',
+            time: 123,
+          },
+          {
+            id: 73,
+            track: 17,
+            title: 'Title B',
+            artist: 'My artist',
+            album: 'Album B',
+            time: 456,
+          },
+        ],
+      }),
+    );
+  });
+
+  describe('when the artist is the active artist', () => {
+    it('should set the active song ID to the first song ID in the list', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(stateWithActiveArtist, action);
+      expect(result.library.activeSongId).toBe(12);
+    });
+  });
+});

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

@@ -0,0 +1,29 @@
+import { ArtistAlbumsLoaded, ArtistSongsLoaded, ArtistsSet } from '../actions';
+import { CmusUIState } from '../types';
+
+export const setArtists = (state: CmusUIState, action: ArtistsSet): CmusUIState => ({
+  ...state,
+  artists: action.payload,
+  library: {
+    ...state.library,
+    activeArtist: action.payload[0] ?? null,
+    activeAlbum: null,
+  },
+});
+
+export const setArtistAlbums = (state: CmusUIState, action: ArtistAlbumsLoaded): CmusUIState => ({
+  ...state,
+  artistAlbums: { ...state.artistAlbums, [action.payload.artist]: action.payload.albums },
+});
+
+export const setArtistSongs = (state: CmusUIState, action: ArtistSongsLoaded): CmusUIState => ({
+  ...state,
+  artistSongs: { ...state.artistSongs, [action.payload.artist]: action.payload.songs },
+  library: {
+    ...state.library,
+    activeSongId:
+      state.library.activeArtist === action.payload.artist
+        ? action.payload.songs[0]?.id ?? null
+        : state.library.activeSongId,
+  },
+});

+ 18 - 0
gmus-web/src/components/ui/cmus/reducer/command.spec.ts

@@ -0,0 +1,18 @@
+import { loggedOut } from '../../../../actions';
+import { CmusUIActionType, commandSet } from '../actions';
+
+import { stateCommandMode } from './fixtures';
+import { cmusUIReducer } from './reducer';
+
+describe(CmusUIActionType.CommandSet, () => {
+  describe('q', () => {
+    const action = commandSet('q');
+
+    it('should set a log out global action', () => {
+      expect.assertions(2);
+      const result = cmusUIReducer(stateCommandMode, action);
+      expect(result.commandMode).toBe(false);
+      expect(result.globalAction).toStrictEqual(loggedOut());
+    });
+  });
+});

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

@@ -0,0 +1,15 @@
+import { loggedOut } from '../../../../actions';
+import { CommandSet } from '../actions';
+import { CmusUIState } from '../types';
+import { withGlobalAction } from './utils';
+
+export function onCommand(state: CmusUIState, action: CommandSet): CmusUIState {
+  const nextState: CmusUIState = { ...state, commandMode: false };
+
+  switch (action.payload) {
+    case 'q':
+      return withGlobalAction(nextState, loggedOut());
+    default:
+      return nextState;
+  }
+}

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

@@ -0,0 +1,34 @@
+import { CmusUIState, LibraryModeWindow, View } from '../types';
+import { initialCmusUIState } from './reducer';
+
+export const stateLibrary: CmusUIState = {
+  ...initialCmusUIState,
+  view: View.Library,
+};
+
+export const stateCommandMode: CmusUIState = {
+  ...stateLibrary,
+  commandMode: true,
+};
+
+export const stateDifferentView: CmusUIState = {
+  ...initialCmusUIState,
+  view: (undefined as unknown) as View,
+  scroll: { delta: 0, serialNumber: 8813 },
+};
+
+export const stateFromMode = (fromModeWindow: LibraryModeWindow): CmusUIState => ({
+  ...stateLibrary,
+  library: {
+    ...stateLibrary.library,
+    modeWindow: fromModeWindow,
+  },
+});
+
+export const stateWithActiveArtist: CmusUIState = {
+  ...initialCmusUIState,
+  library: {
+    ...initialCmusUIState.library,
+    activeArtist: 'My artist',
+  },
+};

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

@@ -0,0 +1 @@
+export * from './reducer';

+ 611 - 0
gmus-web/src/components/ui/cmus/reducer/keypress.spec.ts

@@ -0,0 +1,611 @@
+import { masterSet, playPaused, 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 { cmusUIReducer, initialCmusUIState } from './reducer';
+
+describe(ActionTypeKeyPressed, () => {
+  describe.each`
+    key          | toView
+    ${Keys['1']} | ${View.Library}
+    ${Keys['2']} | ${View.ClientList}
+  `('$key', ({ key, toView }) => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key };
+
+    it(`should set the view to ${toView}`, () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(stateDifferentView, action);
+
+      expect(result.view).toBe(toView);
+    });
+  });
+
+  describe(Keys.tab, () => {
+    describe('when in library view', () => {
+      describe.each`
+        fromModeWindow                  | toModeWindow
+        ${LibraryModeWindow.ArtistList} | ${LibraryModeWindow.SongList}
+        ${LibraryModeWindow.SongList}   | ${LibraryModeWindow.ArtistList}
+      `('when the mode window is $fromModeWindow', ({ fromModeWindow, toModeWindow }) => {
+        it(`should set the mode window to ${toModeWindow}`, () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateFromMode(fromModeWindow), {
+            type: ActionTypeKeyPressed,
+            key: Keys.tab,
+          });
+
+          expect(result.library.modeWindow).toBe(toModeWindow);
+        });
+      });
+    });
+  });
+
+  describe(Keys.B, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.B };
+
+    it('should set skip delta to 1', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.skipSong).toStrictEqual({
+        delta: 1,
+        serialNumber: 1,
+      });
+    });
+  });
+
+  describe(Keys.C, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.C };
+
+    it('should set the globalAction to play/pause', () => {
+      expect.assertions(2);
+      const result = cmusUIReducer(stateLibrary, action);
+      expect(result.globalAction).toStrictEqual(playPaused());
+      expect(result.globalActionSerialNumber).toBe(stateLibrary.globalActionSerialNumber + 1);
+    });
+  });
+
+  describe(Keys.J, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.J };
+
+    describe('when in library view', () => {
+      describe('when in the artist list mode', () => {
+        const stateArtistMode: CmusUIState = {
+          ...stateLibrary,
+          artists: ['Artist A', 'Artist B'],
+          artistAlbums: {
+            'Artist A': ['Album 1', 'Album 2'],
+          },
+          artistSongs: {
+            'Artist A': [
+              { id: 456, album: 'Album 2' } as Song,
+              { id: 123, album: 'Album 1' } as Song,
+            ],
+          },
+          library: {
+            ...stateLibrary.library,
+            activeArtist: 'Artist A',
+            modeWindow: LibraryModeWindow.ArtistList,
+          },
+        };
+
+        it('should set the active artist to the next available artist', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateArtistMode, action);
+
+          expect(result.library.activeArtist).toBe('Artist B');
+        });
+
+        it('should set the active song ID to the first by the artist', () => {
+          expect.assertions(1);
+          const state: CmusUIState = {
+            ...stateArtistMode,
+            artistSongs: {
+              'Artist B': [{ id: 123 } as Song, { id: 456 } as Song],
+            },
+          };
+          const result = cmusUIReducer(state, action);
+
+          expect(result.library.activeSongId).toBe(123);
+        });
+
+        describe('when there are no songs loaded for the artist', () => {
+          it('should set the active song ID to null', () => {
+            expect.assertions(1);
+            const state: CmusUIState = {
+              ...stateArtistMode,
+              artistSongs: {},
+            };
+            const result = cmusUIReducer(state, action);
+
+            expect(result.library.activeSongId).toBeNull();
+          });
+        });
+
+        describe('when the current artist is expanded', () => {
+          const stateArtistModeWithAlbums: CmusUIState = {
+            ...stateArtistMode,
+            library: {
+              ...stateArtistMode.library,
+              expandedArtists: ['Artist A'],
+            },
+          };
+
+          it('should select the next album', () => {
+            expect.assertions(2);
+            const result = cmusUIReducer(stateArtistModeWithAlbums, action);
+
+            expect(result.library.activeArtist).toBe('Artist A');
+            expect(result.library.activeAlbum).toBe('Album 1');
+          });
+
+          it('should set the active song ID to the first matching the album', () => {
+            expect.assertions(1);
+            const result = cmusUIReducer(stateArtistModeWithAlbums, action);
+
+            expect(result.library.activeSongId).toBe(123);
+          });
+        });
+      });
+
+      describe('when in the song list mode', () => {
+        const stateSongsMode: CmusUIState = {
+          ...stateLibrary,
+          artists: ['Artist A'],
+          artistSongs: {
+            'Artist A': [{ id: 123 } as Song, { id: 456 } as Song, { id: 789 } as Song],
+          },
+          library: {
+            ...stateLibrary.library,
+            activeArtist: 'Artist A',
+            activeSongId: 123,
+            modeWindow: LibraryModeWindow.SongList,
+          },
+        };
+
+        it('should set the active song ID to the next available song', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateSongsMode, action);
+
+          expect(result.library.activeSongId).toBe(456);
+        });
+      });
+    });
+
+    describe('when in a different view', () => {
+      it('should set the scroll delta and increment the serial number', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(stateDifferentView, action);
+        expect(result.scroll).toStrictEqual({ delta: 1, serialNumber: 8814 });
+      });
+    });
+  });
+
+  describe(Keys.K, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.K };
+
+    describe('when in library view', () => {
+      describe('when in the artist list mode', () => {
+        const stateArtistMode: CmusUIState = {
+          ...stateLibrary,
+          artists: ['Artist A', 'Artist B'],
+          artistAlbums: {
+            'Artist B': ['Album 1', 'Album 2'],
+          },
+          artistSongs: {
+            'Artist B': [
+              { id: 456, album: 'Album 2' } as Song,
+              { id: 123, album: 'Album 1' } as Song,
+            ],
+          },
+          library: {
+            ...stateLibrary.library,
+            activeArtist: 'Artist B',
+            modeWindow: LibraryModeWindow.ArtistList,
+          },
+        };
+
+        it('should set the active artist to the previous available artist', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateArtistMode, action);
+
+          expect(result.library.activeArtist).toBe('Artist A');
+        });
+
+        it('should set the active song ID to the first by the artist', () => {
+          expect.assertions(1);
+          const state: CmusUIState = {
+            ...stateArtistMode,
+            artistSongs: {
+              'Artist A': [{ id: 123 } as Song, { id: 456 } as Song],
+            },
+          };
+          const result = cmusUIReducer(state, action);
+
+          expect(result.library.activeSongId).toBe(123);
+        });
+
+        describe('when there are no songs loaded for the artist', () => {
+          it('should set the active song ID to null', () => {
+            expect.assertions(1);
+            const state: CmusUIState = {
+              ...stateArtistMode,
+              artistSongs: {},
+            };
+            const result = cmusUIReducer(state, action);
+
+            expect(result.library.activeSongId).toBeNull();
+          });
+        });
+
+        describe('when the current artist is expanded', () => {
+          const stateArtistModeWithAlbums: CmusUIState = {
+            ...stateArtistMode,
+            library: {
+              ...stateArtistMode.library,
+              expandedArtists: ['Artist B'],
+              activeAlbum: 'Album 2',
+            },
+          };
+
+          it('should select the previous album', () => {
+            expect.assertions(2);
+            const result = cmusUIReducer(stateArtistModeWithAlbums, action);
+
+            expect(result.library.activeArtist).toBe('Artist B');
+            expect(result.library.activeAlbum).toBe('Album 1');
+          });
+
+          it('should set the active song ID to the first matching the album', () => {
+            expect.assertions(1);
+            const result = cmusUIReducer(stateArtistModeWithAlbums, action);
+
+            expect(result.library.activeSongId).toBe(123);
+          });
+        });
+      });
+
+      describe('when in the song list mode', () => {
+        const stateSongsMode: CmusUIState = {
+          ...stateLibrary,
+          artists: ['Artist A'],
+          artistSongs: {
+            'Artist A': [{ id: 123 } as Song, { id: 456 } as Song, { id: 789 } as Song],
+          },
+          library: {
+            ...stateLibrary.library,
+            activeArtist: 'Artist A',
+            activeSongId: 456,
+            modeWindow: LibraryModeWindow.SongList,
+          },
+        };
+
+        it('should set the active song ID to the previous available song', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateSongsMode, action);
+
+          expect(result.library.activeSongId).toBe(123);
+        });
+      });
+    });
+
+    describe('when in a different view', () => {
+      it('should set the scroll delta and increment the serial number', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(stateDifferentView, action);
+        expect(result.scroll).toStrictEqual({ delta: -1, serialNumber: 8814 });
+      });
+    });
+  });
+
+  describe(Keys.Z, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.Z };
+
+    it('should set skip delta to -1', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.skipSong).toStrictEqual({
+        delta: -1,
+        serialNumber: 1,
+      });
+    });
+  });
+
+  describe(Keys.pageDown, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.pageDown };
+
+    describe('when in library view', () => {
+      describe('when in the artist list mode', () => {
+        const stateArtistMode: CmusUIState = {
+          ...stateLibrary,
+          artists: Array(26)
+            .fill(0)
+            .map((_, index) => `Artist ${index + 1}`),
+          artistAlbums: {
+            'Artist 3': ['Album 1', 'Album 2'],
+            'Artist 4': ['Album Z'],
+            'Artist 18': ['Album 3'],
+          },
+          artistSongs: {
+            'Artist 18': [{ id: 123, album: 'Album 3' } as Song],
+          },
+          library: {
+            ...stateLibrary.library,
+            activeArtist: 'Artist 1',
+            activeAlbum: null,
+            expandedArtists: ['Artist 3', 'Artist 18'],
+            modeWindow: LibraryModeWindow.ArtistList,
+          },
+        };
+
+        it('should page the active artist and album by 20 rows down', () => {
+          expect.assertions(2);
+          const result = cmusUIReducer(stateArtistMode, action);
+
+          expect(result.library.activeArtist).toBe('Artist 18');
+          expect(result.library.activeAlbum).toBe('Album 3');
+        });
+
+        it('should set the active song ID to the first by the artist', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateArtistMode, action);
+
+          expect(result.library.activeSongId).toBe(123);
+        });
+      });
+
+      describe('when in the song list mode', () => {
+        const stateSongsMode: CmusUIState = {
+          ...stateLibrary,
+          artists: ['Artist A'],
+          artistSongs: {
+            'Artist A': Array(30)
+              .fill(0)
+              .map((_, index) => ({ id: index + 100 } as Song)),
+          },
+          library: {
+            ...stateLibrary.library,
+            activeArtist: 'Artist A',
+            activeSongId: 101,
+            modeWindow: LibraryModeWindow.SongList,
+          },
+        };
+
+        it('should set the active song ID to the one 20th after current', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateSongsMode, action);
+
+          expect(result.library.activeSongId).toBe(121);
+        });
+      });
+    });
+  });
+
+  describe(Keys.pageUp, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.pageUp };
+
+    describe('when in library view', () => {
+      describe('when in the artist list mode', () => {
+        const stateArtistMode: CmusUIState = {
+          ...stateLibrary,
+          artists: Array(26)
+            .fill(0)
+            .map((_, index) => `Artist ${index + 1}`),
+          artistAlbums: {
+            'Artist 3': ['Album 1', 'Album 2'],
+            'Artist 4': ['Album X', 'Album Y', 'Album Z'],
+            'Artist 18': ['Album 3'],
+          },
+          artistSongs: {
+            'Artist 3': [{ id: 123, album: 'Album 1' } as Song],
+          },
+          library: {
+            ...stateLibrary.library,
+            activeArtist: 'Artist 18',
+            activeAlbum: 'Album 3',
+            expandedArtists: ['Artist 3', 'Artist 4', 'Artist 18'],
+            modeWindow: LibraryModeWindow.ArtistList,
+          },
+        };
+
+        it('should page the active artist and album by 20 rows down', () => {
+          expect.assertions(2);
+          const result = cmusUIReducer(stateArtistMode, action);
+
+          expect(result.library.activeArtist).toBe('Artist 3');
+          expect(result.library.activeAlbum).toBe('Album 1');
+        });
+
+        it('should set the active song ID to the first by the artist', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateArtistMode, action);
+
+          expect(result.library.activeSongId).toBe(123);
+        });
+      });
+
+      describe('when in the song list mode', () => {
+        const stateSongsMode: CmusUIState = {
+          ...stateLibrary,
+          artists: ['Artist A'],
+          artistSongs: {
+            'Artist A': Array(30)
+              .fill(0)
+              .map((_, index) => ({ id: index + 100 } as Song)),
+          },
+          library: {
+            ...stateLibrary.library,
+            activeArtist: 'Artist A',
+            activeSongId: 128,
+            modeWindow: LibraryModeWindow.SongList,
+          },
+        };
+
+        it('should set the active song ID to the one 20th prior to current', () => {
+          expect.assertions(1);
+          const result = cmusUIReducer(stateSongsMode, action);
+
+          expect(result.library.activeSongId).toBe(108);
+        });
+      });
+    });
+  });
+
+  describe(Keys.space, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.space };
+
+    describe('when in library view', () => {
+      describe('when in the artist list mode', () => {
+        it('should toggle the active artist', () => {
+          expect.assertions(2);
+
+          const state: CmusUIState = {
+            ...initialCmusUIState,
+            library: {
+              ...initialCmusUIState.library,
+              expandedArtists: [],
+              activeArtist: 'Some artist',
+            },
+          };
+
+          const firstResult = cmusUIReducer(state, action);
+          expect(firstResult.library.expandedArtists).toStrictEqual(['Some artist']);
+
+          const secondResult = cmusUIReducer(firstResult, action);
+          expect(secondResult.library.expandedArtists).toStrictEqual([]);
+        });
+
+        describe('when there is no active artist', () => {
+          it('should return the state', () => {
+            expect.assertions(1);
+            const stateNoActive: CmusUIState = {
+              ...initialCmusUIState,
+              library: {
+                ...initialCmusUIState.library,
+                activeArtist: null,
+              },
+            };
+
+            const result = cmusUIReducer(stateNoActive, action);
+            expect(result).toStrictEqual(stateNoActive);
+          });
+        });
+
+        describe('when the active album will disappear', () => {
+          const stateWithActiveAlbum: CmusUIState = {
+            ...initialCmusUIState,
+            artistAlbums: {
+              'Artist A': ['Album A', 'Album B', 'Album C'],
+            },
+            library: {
+              ...initialCmusUIState.library,
+              activeArtist: 'Artist A',
+              expandedArtists: ['Artist A'],
+              activeAlbum: 'Album B',
+            },
+          };
+
+          it('should set the active album to null', () => {
+            expect.assertions(2);
+            const result = cmusUIReducer(stateWithActiveAlbum, action);
+            expect(result.library.activeArtist).toBe('Artist A');
+            expect(result.library.activeAlbum).toBeNull();
+          });
+        });
+      });
+    });
+  });
+
+  describe(Keys.enter, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.enter };
+
+    describe('when in library view', () => {
+      describe('when in the songs list mode', () => {
+        const state: CmusUIState = {
+          ...initialCmusUIState,
+          globalActionSerialNumber: 1875,
+          library: {
+            ...initialCmusUIState.library,
+            modeWindow: LibraryModeWindow.SongList,
+            activeSongId: 713,
+          },
+        };
+
+        it('should set the globalAction to play the active song and increment the serial', () => {
+          expect.assertions(2);
+
+          const result = cmusUIReducer(state, action);
+
+          expect(result.globalAction).toStrictEqual(
+            stateSet({
+              playing: true,
+              songId: 713,
+              currentTime: 0,
+              seekTime: 0,
+            }),
+          );
+          expect(result.globalActionSerialNumber).toBe(1876);
+        });
+      });
+    });
+
+    describe('when in client list view', () => {
+      const state: CmusUIState = {
+        ...initialCmusUIState,
+        globalActionSerialNumber: 123,
+        view: View.ClientList,
+        clientList: {
+          active: 'some-active-client',
+        },
+      };
+
+      it('should set the globalAction to set the given client to master', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(state, action);
+        expect(result.globalAction).toStrictEqual(masterSet('some-active-client'));
+      });
+    });
+  });
+
+  describe(Keys.esc, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.esc };
+
+    describe.each`
+      overlay
+      ${Overlay.Help}
+    `('when the overlay is set to $overlay', ({ overlay }) => {
+      const stateWithOverlay: CmusUIState = {
+        ...initialCmusUIState,
+        overlay,
+      };
+
+      it('should reset the overlay', () => {
+        expect.assertions(1);
+        const result = cmusUIReducer(stateWithOverlay, action);
+        expect(result.overlay).toBeNull();
+      });
+    });
+  });
+
+  describe(Keys.colon, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.colon };
+
+    it('should enter command mode', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(stateLibrary, action);
+      expect(result.commandMode).toBe(true);
+    });
+  });
+
+  describe(Keys.question, () => {
+    const action: ActionKeyPressed = { type: ActionTypeKeyPressed, key: Keys.question };
+
+    it('should set the overlay to help mode', () => {
+      expect.assertions(1);
+      const result = cmusUIReducer(initialCmusUIState, action);
+      expect(result.overlay).toBe(Overlay.Help);
+    });
+  });
+});

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

@@ -0,0 +1,122 @@
+import { masterSet, playPaused, stateSet } from '../../../../actions';
+import { ActionKeyPressed, Keys } from '../../../../hooks/vim';
+import { CmusUIState, LibraryModeWindow, Overlay, View } from '../types';
+import { handleScroll } from './scroll';
+import { withGlobalAction } from './utils';
+
+const libraryModeWindows: LibraryModeWindow[] = Object.values(LibraryModeWindow);
+
+const switchLibraryMode = (state: CmusUIState): CmusUIState => ({
+  ...state,
+  library: {
+    ...state.library,
+    modeWindow:
+      libraryModeWindows[
+        (libraryModeWindows.indexOf(state.library.modeWindow) + 1) % libraryModeWindows.length
+      ],
+  },
+});
+
+function toggleExpandArtist(library: CmusUIState['library']): CmusUIState['library'] {
+  if (library.activeArtist === null) {
+    return library;
+  }
+  if (library.expandedArtists.includes(library.activeArtist)) {
+    return {
+      ...library,
+      expandedArtists: library.expandedArtists.filter(
+        (compare) => compare !== library.activeArtist,
+      ),
+      activeAlbum: null,
+    };
+  }
+  return { ...library, expandedArtists: [...library.expandedArtists, library.activeArtist] };
+}
+
+function handleActivate(state: CmusUIState): CmusUIState {
+  switch (state.view) {
+    case View.Library:
+      if (state.library.modeWindow === LibraryModeWindow.SongList) {
+        if (!state.library.activeSongId) {
+          return state;
+        }
+
+        return withGlobalAction(
+          state,
+          stateSet({
+            playing: true,
+            songId: state.library.activeSongId,
+            currentTime: 0,
+            seekTime: 0,
+          }),
+        );
+      }
+      return state;
+
+    case View.ClientList:
+      if (!state.clientList.active) {
+        return state;
+      }
+      return withGlobalAction(state, masterSet(state.clientList.active));
+
+    default:
+      return state;
+  }
+}
+
+export function handleKeyPress(state: CmusUIState, action: ActionKeyPressed): CmusUIState {
+  switch (action.key) {
+    case Keys.colon:
+      return { ...state, commandMode: true };
+
+    case Keys['1']:
+      return { ...state, view: View.Library };
+    case Keys['2']:
+      return { ...state, view: View.ClientList };
+
+    case Keys.tab:
+      if (state.view === View.Library) {
+        return switchLibraryMode(state);
+      }
+      return state;
+
+    case Keys.space:
+      if (state.view === View.Library) {
+        if (state.library.modeWindow === LibraryModeWindow.ArtistList) {
+          return { ...state, library: toggleExpandArtist(state.library) };
+        }
+      }
+      return state;
+
+    case Keys.enter:
+      return handleActivate(state);
+
+    case Keys.esc:
+      return { ...state, overlay: null };
+
+    case Keys.question:
+      return { ...state, overlay: Overlay.Help };
+
+    case Keys.B:
+      return { ...state, skipSong: { delta: 1, serialNumber: state.skipSong.serialNumber + 1 } };
+
+    case Keys.Z:
+      return { ...state, skipSong: { delta: -1, serialNumber: state.skipSong.serialNumber + 1 } };
+
+    case Keys.C:
+      return withGlobalAction(state, playPaused());
+
+    case Keys.J:
+      return handleScroll(state, 1);
+    case Keys.K:
+      return handleScroll(state, -1);
+
+    case Keys.pageDown:
+      return handleScroll(state, 20);
+    case Keys.pageUp:
+      return handleScroll(state, -20);
+
+    default:
+      return state;
+  }
+}

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

@@ -0,0 +1,12 @@
+import { clientActivated, CmusUIActionType } from '../actions';
+import { cmusUIReducer, initialCmusUIState } from './reducer';
+
+describe(CmusUIActionType.ClientActivated, () => {
+  const action = clientActivated('some-client');
+
+  it('should set the active client', () => {
+    expect.assertions(1);
+    const result = cmusUIReducer(initialCmusUIState, action);
+    expect(result.clientList.active).toBe('some-client');
+  });
+});

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

@@ -0,0 +1,60 @@
+import { createContext, Dispatch } from 'react';
+
+import { nullDispatch } from '../../../../context/state';
+import { ActionTypeKeyPressed } from '../../../../hooks/vim';
+import { CmusUIAction, CmusUIActionType } from '../actions';
+import { CmusUIState, LibraryModeWindow, View } from '../types';
+
+import { setArtistAlbums, setArtists, setArtistSongs } from './artists';
+import { onCommand } from './command';
+import { handleKeyPress } from './keypress';
+
+export const initialCmusUIState: CmusUIState = {
+  globalAction: null,
+  globalActionSerialNumber: 0,
+  scroll: { delta: 0, serialNumber: 0 },
+  skipSong: { delta: 0, serialNumber: 0 },
+  view: View.Library,
+  commandMode: false,
+  overlay: null,
+  artists: [],
+  artistAlbums: {},
+  artistSongs: {},
+  library: {
+    modeWindow: LibraryModeWindow.ArtistList,
+    expandedArtists: [],
+    activeArtist: null,
+    activeAlbum: null,
+    activeSongId: null,
+    visibleSongs: [],
+  },
+  clientList: {
+    active: null,
+  },
+};
+
+export const CmusUIStateContext = createContext<CmusUIState>(initialCmusUIState);
+export const CmusUIDispatchContext = createContext<Dispatch<CmusUIAction>>(nullDispatch);
+
+export function cmusUIReducer(state: CmusUIState, action: CmusUIAction): CmusUIState {
+  switch (action.type) {
+    case ActionTypeKeyPressed:
+      return handleKeyPress(state, action);
+
+    case CmusUIActionType.ArtistsSet:
+      return setArtists(state, action);
+    case CmusUIActionType.ArtistAlbumsLoaded:
+      return setArtistAlbums(state, action);
+    case CmusUIActionType.ArtistSongsLoaded:
+      return setArtistSongs(state, action);
+
+    case CmusUIActionType.CommandSet:
+      return onCommand(state, action);
+
+    case CmusUIActionType.ClientActivated:
+      return { ...state, clientList: { active: action.payload } };
+
+    default:
+      return state;
+  }
+}

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

@@ -0,0 +1,78 @@
+import { Song } from '../../../../types';
+import { scrollThroughItems } from '../../../../utils/delta';
+import { CmusUIState, LibraryModeWindow, View } from '../types';
+import { getNextActiveArtistAndAlbum } from '../utils/scroll';
+
+const scrollSongs = (state: CmusUIState, delta: number): CmusUIState =>
+  state.library.activeArtist === null
+    ? state
+    : {
+        ...state,
+        library: {
+          ...state.library,
+          activeSongId: scrollThroughItems(
+            state.artistSongs[state.library.activeArtist] ?? [],
+            (compare) => compare.id === state.library.activeSongId,
+            delta,
+          ).id,
+        },
+      };
+
+function getActiveSongIdFromActiveArtistAlbum(
+  activeArtist: string | null,
+  activeAlbum: string | null,
+  artistSongs: Record<string, Song[]>,
+): number | null {
+  if (activeArtist === null) {
+    return null;
+  }
+  const songs = artistSongs[activeArtist] ?? [];
+  if (!activeAlbum) {
+    return songs[0]?.id ?? null;
+  }
+  return songs.find((compare) => compare.album === activeAlbum)?.id ?? null;
+}
+
+function scrollArtists(state: CmusUIState, delta: number): CmusUIState {
+  const { artist, album } = getNextActiveArtistAndAlbum(
+    state.artists,
+    state.artistAlbums,
+    state.library.activeArtist,
+    state.library.activeAlbum,
+    state.library.expandedArtists,
+    delta,
+  );
+
+  return {
+    ...state,
+    library: {
+      ...state.library,
+      activeArtist: artist,
+      activeAlbum: album,
+      activeSongId: getActiveSongIdFromActiveArtistAlbum(artist, album, state.artistSongs),
+    },
+  };
+}
+
+function handleScrollLibrary(state: CmusUIState, delta: number): CmusUIState {
+  switch (state.library.modeWindow) {
+    case LibraryModeWindow.ArtistList:
+      return scrollArtists(state, delta);
+    case LibraryModeWindow.SongList:
+      return scrollSongs(state, delta);
+    default:
+      return state;
+  }
+}
+
+export function handleScroll(state: CmusUIState, delta: number): CmusUIState {
+  switch (state.view) {
+    case View.Library:
+      return handleScrollLibrary(state, delta);
+    default:
+      return {
+        ...state,
+        scroll: { delta, serialNumber: state.scroll.serialNumber + 1 },
+      };
+  }
+}

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

@@ -0,0 +1,8 @@
+import { LocalAction } from '../../../../actions';
+import { CmusUIState } from '../types';
+
+export const withGlobalAction = (state: CmusUIState, action: LocalAction): CmusUIState => ({
+  ...state,
+  globalAction: action,
+  globalActionSerialNumber: state.globalActionSerialNumber + 1,
+});

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

@@ -22,6 +22,10 @@ export type CmusUIState = {
     delta: number;
     serialNumber: number;
   };
+  skipSong: {
+    delta: 0 | 1 | -1;
+    serialNumber: number;
+  };
   view: View;
   commandMode: boolean;
   overlay: Overlay | null;

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

@@ -7,7 +7,9 @@ import * as Styled from './help.styles';
 type Command = { command: string; description: string };
 
 const commandsGeneral: Command[] = [
+  { command: 'z', description: 'previous song' },
   { command: 'c', description: 'play / pause' },
+  { command: 'b', description: 'next song' },
   { command: 'j', description: 'select next list item' },
   { command: 'k', description: 'select previous list item' },
   { command: '<PageDown>', description: 'select next page of list items' },

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

@@ -1,4 +1,4 @@
-import React, { useContext, useEffect } from 'react';
+import React, { useContext, useEffect, useRef } from 'react';
 import { useReducer } from 'reinspect';
 
 import { DispatchContext } from '../../../context/state';
@@ -25,7 +25,7 @@ import * as Styled from './wrapper.styles';
 
 const viewTitles = Object.values(View);
 
-export const CmusUIProvider: UIProviderComponent = ({ currentSong }) => {
+export const CmusUIProvider: UIProviderComponent = ({ currentSong, nextSong, prevSong }) => {
   const dispatch = useContext(DispatchContext);
   const [stateUI, dispatchUI] = useReducer(cmusUIReducer, initialCmusUIState, init, 'ui');
 
@@ -35,6 +35,19 @@ export const CmusUIProvider: UIProviderComponent = ({ currentSong }) => {
     }
   }, [dispatch, stateUI.globalAction, stateUI.globalActionSerialNumber]);
 
+  const lastSkipSerialNumber = useRef<number>(0);
+  useEffect(() => {
+    if (lastSkipSerialNumber.current !== stateUI.skipSong.serialNumber) {
+      lastSkipSerialNumber.current = stateUI.skipSong.serialNumber;
+
+      if (stateUI.skipSong.delta === 1) {
+        nextSong();
+      } else if (stateUI.skipSong.delta === -1) {
+        prevSong();
+      }
+    }
+  }, [stateUI.skipSong, nextSong, prevSong]);
+
   useVimBindings(dispatchUI, stateUI.commandMode);
 
   useLibrary(stateUI, dispatchUI);

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

@@ -9,6 +9,8 @@ export enum UIProvider {
 export type UIProps = {
   isMaster: boolean;
   currentSong: Song | null;
+  nextSong: () => void;
+  prevSong: () => void;
 };
 
 export type UIProviderComponent = React.FC<UIProps>;

+ 53 - 78
gmus-web/src/hooks/fetch/artists.ts

@@ -1,65 +1,65 @@
 import { AxiosInstance, AxiosResponse } from 'axios';
-import { useCallback, useMemo, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
 
 import { Song } from '../../types/songs';
 import { getApiUrl } from '../../utils/url';
-import { useCancellableRequest } from '../request';
+import { useRequestCallback } from '../request';
 
 type ArtistsResponse = {
   artists: string[];
 };
 
-const sendArtistsRequest = (axios: AxiosInstance): Promise<AxiosResponse<ArtistsResponse>> =>
-  axios.get(`${getApiUrl()}/artists`);
-
-type AlbumsResponse = {
-  artist: string;
-  albums: string[];
-};
-
-type AlbumsQuery = {
-  artist: string;
-};
-
-const sendAlbumsRequest = (
-  axios: AxiosInstance,
-  query: AlbumsQuery,
-): Promise<AxiosResponse<AlbumsResponse>> =>
-  axios.get(`${getApiUrl()}/albums?artist=${encodeURIComponent(query.artist)}`);
-
-type SongsResponse = {
-  artist: string;
-  songs: Song[];
-};
-
-type SongsQuery = AlbumsQuery;
-
-const sendSongsRequest = (
-  axios: AxiosInstance,
-  query: SongsQuery,
-): Promise<AxiosResponse<SongsResponse>> =>
-  axios.get(`${getApiUrl()}/songs?artist=${encodeURIComponent(query.artist)}`);
-
 export function useArtists(): ArtistsResponse & {
   fetching: boolean;
 } {
-  const [artists, setArtists] = useState<string[]>([]);
+  const sendRequest = useCallback(
+    (axios: AxiosInstance): Promise<AxiosResponse<ArtistsResponse>> =>
+      axios.get(`${getApiUrl()}/artists`),
+    [],
+  );
+
+  const [onFetch, response, fetching] = useRequestCallback<void, ArtistsResponse>({
+    sendRequest,
+  });
+
+  useEffect(onFetch, [onFetch]);
 
-  const [pause, setPause] = useState<boolean>(false);
+  return { artists: response?.artists ?? [], fetching };
+}
 
-  const handleResponse = useCallback((response: ArtistsResponse) => {
-    setArtists((last) => Array.from(new Set([...last, ...response.artists])));
-    setPause(true);
-  }, []);
+type ArtistDependencyResponse<K extends string, T> = { artist: string } & { [key in K]: T[] };
 
-  const fetching = useCancellableRequest<void, ArtistsResponse>({
-    query: undefined,
-    pause,
-    sendRequest: sendArtistsRequest,
-    handleResponse,
+function useArtistDependency<K extends string, T>(
+  key: K,
+  artist: string,
+  pause: boolean,
+): [ArtistDependencyResponse<K, T> | null, boolean] {
+  const sendRequest = useCallback(
+    (axios: AxiosInstance, query: string): Promise<AxiosResponse<ArtistDependencyResponse<K, T>>> =>
+      axios.get(`${getApiUrl()}/${key}?artist=${encodeURIComponent(query)}`),
+    [key],
+  );
+
+  const [onFetch, response, fetching] = useRequestCallback<string, ArtistDependencyResponse<K, T>>({
+    sendRequest,
   });
 
-  return { artists, fetching };
+  const [hasLoadedByArtist, setHasLoadedByArtist] = useState<Record<string, boolean>>({});
+  const hasLoadedThisArtist = !!hasLoadedByArtist[artist];
+
+  useEffect(() => {
+    if (!pause && !hasLoadedThisArtist) {
+      onFetch(artist);
+    }
+  }, [onFetch, pause, hasLoadedThisArtist, artist]);
+
+  useEffect(() => {
+    if (response) {
+      setHasLoadedByArtist((last) => ({ ...last, [response.artist]: true }));
+    }
+  }, [response]);
+
+  return [response, fetching];
 }
 
 export function useArtistsAlbumsAndSongs(
@@ -67,42 +67,17 @@ export function useArtistsAlbumsAndSongs(
   pauseAlbums: boolean,
   pauseSongs: boolean,
 ): {
-  albums: AlbumsResponse | undefined;
-  songs: SongsResponse | undefined;
+  albums: ArtistDependencyResponse<'albums', string> | null;
+  songs: ArtistDependencyResponse<'songs', Song> | null;
   fetchingAlbums: boolean;
   fetchingSongs: boolean;
 } {
-  const [hasLoadedAlbums, setHasLoadedAlbums] = useState<Record<string, boolean>>({});
-  const [hasLoadedSongs, setHasLoadedSongs] = useState<Record<string, boolean>>({});
-
-  const query = useMemo<AlbumsQuery>(() => ({ artist }), [artist]);
-
-  const [albums, setAlbums] = useState<AlbumsResponse | undefined>();
-  const [songs, setSongs] = useState<SongsResponse | undefined>();
-
-  const handleAlbumsResponse = useCallback((response: AlbumsResponse) => {
-    setAlbums(response);
-    setHasLoadedAlbums((last) => ({ ...last, [response.artist]: true }));
-  }, []);
-
-  const handleSongsResponse = useCallback((response: SongsResponse) => {
-    setSongs(response);
-    setHasLoadedSongs((last) => ({ ...last, [response.artist]: true }));
-  }, []);
-
-  const fetchingAlbums = useCancellableRequest<AlbumsQuery, AlbumsResponse>({
-    query,
-    pause: pauseAlbums || hasLoadedAlbums[artist],
-    sendRequest: sendAlbumsRequest,
-    handleResponse: handleAlbumsResponse,
-  });
-
-  const fetchingSongs = useCancellableRequest<SongsQuery, SongsResponse>({
-    query,
-    pause: pauseSongs || hasLoadedSongs[artist],
-    sendRequest: sendSongsRequest,
-    handleResponse: handleSongsResponse,
-  });
+  const [albums, fetchingAlbums] = useArtistDependency<'albums', string>(
+    'albums',
+    artist,
+    pauseAlbums,
+  );
+  const [songs, fetchingSongs] = useArtistDependency<'songs', Song>('songs', artist, pauseSongs);
 
   return {
     albums,

+ 23 - 12
gmus-web/src/hooks/master.spec.tsx

@@ -1,8 +1,9 @@
-import { act, render } from '@testing-library/react';
+import { act, render, RenderResult } from '@testing-library/react';
 import React from 'react';
 
 import { masterSet, stateSet } from '../actions';
 import { masterStateUpdateTimeout } from '../constants/system';
+import { DispatchContext, StateContext } from '../context/state';
 import { GlobalState, initialState, nullPlayer } from '../reducer';
 
 import { useMaster } from './master';
@@ -10,11 +11,21 @@ import { useMaster } from './master';
 describe(useMaster.name, () => {
   const dispatch = jest.fn();
 
-  const TestComponent: React.FC<GlobalState> = (state) => {
-    useMaster(state, dispatch);
+  const TestComponent: React.FC = () => {
+    useMaster();
     return null;
   };
 
+  const setup = (state: GlobalState, options: Partial<RenderResult> = {}): RenderResult =>
+    render(
+      <StateContext.Provider value={state}>
+        <DispatchContext.Provider value={dispatch}>
+          <TestComponent />
+        </DispatchContext.Provider>
+      </StateContext.Provider>,
+      options,
+    );
+
   describe('when there is no master initially', () => {
     const stateNoMaster: GlobalState = {
       ...initialState,
@@ -28,7 +39,7 @@ describe(useMaster.name, () => {
 
     it('should take control of master', () => {
       expect.assertions(2);
-      const { unmount } = render(<TestComponent {...stateNoMaster} />);
+      const { unmount } = setup(stateNoMaster);
 
       expect(dispatch).toHaveBeenCalledTimes(1);
       expect(dispatch).toHaveBeenCalledWith(stateSet({ master: 'my-client-name' }));
@@ -44,7 +55,7 @@ describe(useMaster.name, () => {
 
       it('should not take control of master', () => {
         expect.assertions(1);
-        const { unmount } = render(<TestComponent {...stateNoMasterUninit} />);
+        const { unmount } = setup(stateNoMasterUninit);
 
         expect(dispatch).not.toHaveBeenCalled();
 
@@ -84,10 +95,10 @@ describe(useMaster.name, () => {
       expect.assertions(2);
       jest.useFakeTimers();
 
-      const { container, unmount } = render(<TestComponent {...stateWithMaster} />);
+      const { container, unmount } = setup(stateWithMaster);
 
       act(() => {
-        render(<TestComponent {...stateMasterWentAway} />, { container });
+        setup(stateMasterWentAway, { container });
       });
 
       expect(dispatch).not.toHaveBeenCalled();
@@ -119,14 +130,14 @@ describe(useMaster.name, () => {
         expect.assertions(1);
         jest.useFakeTimers();
 
-        const { container, unmount } = render(<TestComponent {...stateWithMaster} />);
+        const { container, unmount } = setup(stateWithMaster);
         act(() => {
-          render(<TestComponent {...stateMasterWentAway} />, { container });
+          setup(stateMasterWentAway, { container });
         });
 
         setImmediate(() => {
           act(() => {
-            render(<TestComponent {...stateMasterWentAwayAnotherTookControl} />, { container });
+            setup(stateMasterWentAwayAnotherTookControl, { container });
           });
         });
 
@@ -155,7 +166,7 @@ describe(useMaster.name, () => {
     it('should continually refresh the server with the current state', () => {
       expect.assertions(6);
       const clock = jest.useFakeTimers();
-      const { unmount } = render(<TestComponent {...stateMaster} />);
+      const { unmount } = setup(stateMaster);
 
       act(() => {
         clock.runTimersToTime(masterStateUpdateTimeout - 1);
@@ -202,7 +213,7 @@ describe(useMaster.name, () => {
     it('should not send state updates periodically', () => {
       expect.assertions(1);
       const clock = jest.useFakeTimers();
-      const { unmount } = render(<TestComponent {...stateSlave} />);
+      const { unmount } = setup(stateSlave);
 
       act(() => {
         clock.runTimersToTime(masterStateUpdateTimeout);

+ 6 - 4
gmus-web/src/hooks/master.ts

@@ -1,11 +1,13 @@
-import { Dispatch, useEffect, useRef } from 'react';
+import { useContext, useEffect, useRef } from 'react';
 
-import { LocalAction, masterSet, stateSet } from '../actions';
+import { masterSet, stateSet } from '../actions';
 import { masterStateUpdateTimeout } from '../constants/system';
-import { GlobalState } from '../reducer';
+import { DispatchContext, StateContext } from '../context/state';
 import { isMaster } from '../selectors';
 
-export function useMaster(state: GlobalState, dispatch: Dispatch<LocalAction>): void {
+export function useMaster(): void {
+  const state = useContext(StateContext);
+  const dispatch = useContext(DispatchContext);
   const clientIsMaster = isMaster(state);
 
   const masterUpdateTimer = useRef<number>(0);

+ 51 - 0
gmus-web/src/hooks/queue.ts

@@ -0,0 +1,51 @@
+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 { NullSong, Song, songExists } from '../types';
+import { getApiUrl } from '../utils/url';
+
+import { useRequestCallback } from './request';
+
+function useNextOrPrevSong(
+  key: 'next' | 'prev',
+  dispatch: Dispatch<LocalAction>,
+): [(songId: number) => void, boolean] {
+  const sendRequest = useCallback(
+    (axios: AxiosInstance, id: number): Promise<AxiosResponse<Song | NullSong>> =>
+      axios.get(`${getApiUrl()}/${key}-song?id=${id}`),
+    [key],
+  );
+
+  const [onRequest, response, loading] = useRequestCallback<number, Song | NullSong>({
+    sendRequest,
+  });
+
+  useEffect(() => {
+    if (response) {
+      if (songExists(response)) {
+        dispatch(songInfoFetched(response, true));
+      } else {
+        dispatch(stateSet({ songId: null, playing: false, seekTime: -1 }));
+      }
+    }
+  }, [dispatch, response]);
+
+  const debouncedRequest = useThrottleCallback(onRequest, 5, true);
+
+  return [debouncedRequest, loading];
+}
+
+export function usePlayQueue(): {
+  onNext: (songId: number) => void;
+  onPrev: (songId: number) => void;
+  loading: boolean;
+} {
+  const dispatch = useContext(DispatchContext);
+
+  const [onRequestNext, loadingNext] = useNextOrPrevSong('next', dispatch);
+  const [onRequestPrev, loadingPrev] = useNextOrPrevSong('prev', dispatch);
+
+  return { onNext: onRequestNext, onPrev: onRequestPrev, loading: loadingNext || loadingPrev };
+}

+ 125 - 0
gmus-web/src/hooks/request.spec.tsx

@@ -0,0 +1,125 @@
+import { act, fireEvent, render, RenderResult, waitFor } from '@testing-library/react';
+import { AxiosInstance, AxiosResponse } from 'axios';
+import nock from 'nock';
+import React from 'react';
+
+import { useRequestCallback } from './request';
+
+describe(useRequestCallback.name, () => {
+  type MyQuery = { something: string };
+  type MyResponse = { result: number };
+
+  const onError = jest.fn();
+  const sendRequest = (axios: AxiosInstance, query: MyQuery): Promise<AxiosResponse<MyResponse>> =>
+    axios.get(`http://my-api.url:1234/my/request?something=${query.something}`);
+
+  const TestComponent: React.FC = () => {
+    const [onRequest, response, loading, cancel] = useRequestCallback<MyQuery, MyResponse>({
+      onError,
+      sendRequest,
+    });
+
+    return (
+      <>
+        <button onClick={(): void => onRequest({ something: 'yes' })}>Send request!</button>
+        <button onClick={(): void => cancel.current?.()}>Cancel!</button>
+        <div data-testid="response">{JSON.stringify(response)}</div>
+        <div data-testid="loading">{loading ? 'Loading' : 'Not loading'}</div>
+      </>
+    );
+  };
+
+  afterEach(() => {
+    nock.cleanAll();
+  });
+
+  const setupRequest = (): RenderResult => {
+    const renderResult = render(<TestComponent />);
+    act(() => {
+      fireEvent.click(renderResult.getByText('Send request!'));
+    });
+    return renderResult;
+  };
+
+  it('should return null as the initial response', () => {
+    expect.assertions(1);
+    const { getByTestId } = render(<TestComponent />);
+    expect(JSON.parse(getByTestId('response').innerHTML)).toBeNull();
+  });
+
+  it('should initially set the loading state to false', () => {
+    expect.assertions(1);
+    const { getByTestId } = render(<TestComponent />);
+    expect(getByTestId('loading')).toHaveTextContent('Not loading');
+  });
+
+  describe('when sending a request', () => {
+    beforeEach(() => {
+      nock('http://my-api.url:1234')
+        .get('/my/request?something=yes')
+        .reply(200, { result: 125 }, { 'Access-Control-Allow-Origin': '*' });
+    });
+
+    it('should set the loading state to true', async () => {
+      expect.assertions(1);
+      const { getByTestId, unmount } = setupRequest();
+      expect(getByTestId('loading')).toHaveTextContent('Loading');
+      unmount();
+    });
+
+    it('should set the response and loading state back to false', async () => {
+      expect.assertions(3);
+
+      const { getByTestId, unmount } = setupRequest();
+
+      await waitFor(() => {
+        expect(getByTestId('loading')).toHaveTextContent('Not loading');
+      });
+
+      expect(JSON.parse(getByTestId('response').innerHTML)).toStrictEqual({ result: 125 });
+      unmount();
+    });
+
+    describe('when the request is cancelled', () => {
+      it('should set the loading state back to false and not set the response', async () => {
+        expect.assertions(4);
+
+        const { getByText, getByTestId, unmount } = setupRequest();
+        act(() => {
+          fireEvent.click(getByText('Cancel!'));
+        });
+
+        expect(getByTestId('loading')).toHaveTextContent('Loading');
+
+        await waitFor(() => {
+          expect(getByTestId('loading')).toHaveTextContent('Not loading');
+        });
+
+        expect(JSON.parse(getByTestId('response').innerHTML)).toBeNull();
+
+        unmount();
+      });
+    });
+  });
+
+  describe('when an error occurs', () => {
+    beforeEach(() => {
+      nock('http://my-api.url:1234')
+        .get('/my/request?something=yes')
+        .reply(500, 'Some error occurred', { 'Access-Control-Allow-Origin': '*' });
+
+      onError.mockClear();
+    });
+
+    it('should call onError', async () => {
+      expect.assertions(3);
+      const { unmount } = setupRequest();
+
+      await waitFor(() => {
+        expect(onError).toHaveBeenCalledTimes(1);
+        expect(onError).toHaveBeenCalledWith(new Error('Request failed with status code 500'));
+      });
+      unmount();
+    });
+  });
+});

+ 53 - 55
gmus-web/src/hooks/request.ts

@@ -1,72 +1,70 @@
 import axios, { Canceler, AxiosInstance, AxiosResponse } from 'axios';
-import { useEffect, useRef, useState } from 'react';
+import { RefObject, useCallback, useEffect, useRef, useState } from 'react';
 
 type Options<Query, Response> = {
-  query: Query;
-  pause?: boolean;
   sendRequest: (axiosInstance: AxiosInstance, query: Query) => Promise<AxiosResponse<Response>>;
-  handleResponse: (res: Response, query: Query) => void;
   onError?: (err: Error) => void;
-  onClear?: () => void;
-  debounceDelay?: number;
 };
 
-export function useCancellableRequest<Query, Response = void>({
-  query,
-  pause,
-  sendRequest,
-  handleResponse,
+export function useRequestCallback<Query, Response = void>({
   onError,
-  onClear,
-}: Options<Query, Response>): boolean {
+  sendRequest,
+}: Options<Query, Response>): [
+  (query: Query) => void,
+  Response | null,
+  boolean,
+  RefObject<((unmount?: boolean) => void) | undefined>,
+] {
+  const [response, setResponse] = useState<Response | null>(null);
   const [loading, setLoading] = useState<boolean>(false);
 
-  const cancelRequest = useRef<Canceler>();
+  const cancel = useRef<(unmount?: boolean) => void>();
 
-  useEffect(() => {
-    setLoading(!!query);
-    if (!query) {
-      onClear?.();
-    }
-  }, [query, onClear]);
+  useEffect(() => (): void => cancel.current?.(true), []);
 
-  useEffect(() => {
-    let cancelled = false;
-    const request = async (): Promise<void> => {
-      try {
-        const axiosWithToken = axios.create({
-          cancelToken: new axios.CancelToken((token): void => {
-            cancelRequest.current = token;
-          }),
-        });
-        const res = await sendRequest(axiosWithToken, query);
-        if (cancelled) {
-          return;
-        }
+  const onRequest = useCallback(
+    (query: Query) => {
+      let cancelled = false;
+      let unmounted = false;
 
-        handleResponse(res.data, query);
-      } catch (err) {
-        if (!axios.isCancel(err)) {
-          onError?.(err);
-        }
-      } finally {
-        if (!cancelled) {
-          setLoading(false);
-        }
-      }
-    };
+      let cancelRequest: Canceler | null = null;
 
-    if (!pause) {
-      request();
-    }
+      cancel.current?.();
+      cancel.current = (unmount = false): void => {
+        cancelled = true;
+        unmounted = unmount;
+        cancelRequest?.();
+      };
+
+      const axiosWithToken = axios.create({
+        cancelToken: new axios.CancelToken((token): void => {
+          cancelRequest = token;
+        }),
+      });
+
+      const makeRequest = async (): Promise<void> => {
+        try {
+          setLoading(true);
+          const res = await sendRequest(axiosWithToken, query);
+
+          if (!cancelled) {
+            setResponse(res.data);
+          }
+        } catch (err) {
+          if (!axios.isCancel(err)) {
+            onError?.(err);
+          }
+        } finally {
+          if (!unmounted) {
+            setLoading(false);
+          }
+        }
+      };
 
-    return (): void => {
-      cancelled = true;
-      if (cancelRequest.current) {
-        cancelRequest.current();
-      }
-    };
-  }, [sendRequest, handleResponse, onError, query, pause]);
+      makeRequest();
+    },
+    [onError, sendRequest],
+  );
 
-  return loading;
+  return [onRequest, response, loading, cancel];
 }

+ 97 - 24
gmus-web/src/hooks/status.spec.tsx

@@ -1,45 +1,118 @@
-import { render, waitFor } from '@testing-library/react';
+import { render, RenderResult, waitFor } from '@testing-library/react';
 import nock from 'nock';
 import React from 'react';
+import { songInfoFetched } from '../actions';
+import { DispatchContext, StateContext } from '../context/state';
+import { GlobalState, initialState } from '../reducer';
 import { Song } from '../types';
 
 import { useCurrentlyPlayingSongInfo } from './status';
 
 describe(useCurrentlyPlayingSongInfo.name, () => {
-  const TestComponent: React.FC<{ songId: number | null }> = ({ songId }) => {
-    const songInfo = useCurrentlyPlayingSongInfo(songId);
-    return <div data-testid="info">{JSON.stringify(songInfo)}</div>;
+  const TestComponent: React.FC = () => {
+    useCurrentlyPlayingSongInfo();
+    return null;
+  };
+
+  const dispatch = jest.fn();
+
+  const setup = (state: GlobalState): RenderResult =>
+    render(
+      <StateContext.Provider value={state}>
+        <DispatchContext.Provider value={dispatch}>
+          <TestComponent />
+        </DispatchContext.Provider>
+      </StateContext.Provider>,
+    );
+
+  const testSong: Song = {
+    id: 1765,
+    track: 12,
+    title: 'My song',
+    artist: 'My artist',
+    album: 'My album',
+    time: 218,
   };
 
   describe('when there is no song ID', () => {
-    it('should return null', () => {
-      expect.assertions(1);
-      const { getByTestId } = render(<TestComponent songId={null} />);
-      expect(JSON.parse(getByTestId('info').innerHTML)).toBeNull();
+    const stateNoId: GlobalState = {
+      ...initialState,
+      player: {
+        ...initialState.player,
+        songId: null,
+      },
+    };
+
+    describe('when there is no song info in state', () => {
+      const stateNoIdNoInfo: GlobalState = {
+        ...stateNoId,
+        songInfo: null,
+      };
+
+      it('should not do anything', () => {
+        expect.assertions(1);
+        setup(stateNoIdNoInfo);
+        expect(dispatch).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when there is song info in state', () => {
+      const stateNoIdWithInfo: GlobalState = {
+        ...stateNoId,
+        songInfo: testSong,
+      };
+
+      it('should dispatch an action to clear the current info', () => {
+        expect.assertions(2);
+        setup(stateNoIdWithInfo);
+        expect(dispatch).toHaveBeenCalledTimes(1);
+        expect(dispatch).toHaveBeenCalledWith(songInfoFetched(null));
+      });
     });
   });
 
   describe('when there is a song ID in state', () => {
-    const testSong: Song = {
-      id: 1765,
-      track: 12,
-      title: 'My song',
-      artist: 'My artist',
-      album: 'My album',
-      time: 218,
+    const stateWithSongId: GlobalState = {
+      ...initialState,
+      player: {
+        ...initialState.player,
+        songId: testSong.id,
+      },
     };
 
-    beforeEach(() => {
-      nock('http://my-api.url:1234')
-        .get('/song-info?id=1765')
-        .reply(200, testSong, { 'Access-Control-Allow-Origin': '*' });
+    describe('when the song info is already fetched for the playing song ID', () => {
+      const stateFetched: GlobalState = {
+        ...stateWithSongId,
+        songInfo: testSong,
+      };
+
+      it('should not do anything', () => {
+        expect.assertions(1);
+        setup(stateFetched);
+        expect(dispatch).not.toHaveBeenCalled();
+      });
     });
 
-    it('should return the song info from the API', async () => {
-      expect.assertions(2);
-      const { getByTestId } = render(<TestComponent songId={1765} />);
-      await waitFor(() => {
-        expect(JSON.parse(getByTestId('info').innerHTML)).toStrictEqual(testSong);
+    describe('when the song info is stale', () => {
+      const stateStale: GlobalState = {
+        ...stateWithSongId,
+        songInfo: { ...testSong, id: testSong.id + 1 },
+      };
+
+      beforeEach(() => {
+        nock('http://my-api.url:1234')
+          .get('/song-info?id=1765')
+          .reply(200, testSong, { 'Access-Control-Allow-Origin': '*' });
+      });
+
+      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);
+        });
+
+        expect(dispatch).toHaveBeenCalledWith(songInfoFetched(testSong));
       });
     });
   });

+ 30 - 20
gmus-web/src/hooks/status.ts

@@ -1,30 +1,40 @@
 import { AxiosInstance, AxiosResponse } from 'axios';
-import { useCallback, useMemo, useState } from 'react';
+import { useCallback, useContext, useEffect } from 'react';
+import { songInfoFetched } from '../actions';
+import { DispatchContext, StateContext } from '../context/state';
 
 import { Song } from '../types';
 import { getApiUrl } from '../utils/url';
-import { useCancellableRequest } from './request';
+import { useRequestCallback } from './request';
 
-type SongInfoQuery = { id: number };
+export function useCurrentlyPlayingSongInfo(): void {
+  const state = useContext(StateContext);
+  const dispatch = useContext(DispatchContext);
 
-const sendSongInfoRequest = (
-  axios: AxiosInstance,
-  query: SongInfoQuery,
-): Promise<AxiosResponse<Song>> => axios.get(`${getApiUrl()}/song-info?id=${query.id}`);
+  const sendRequest = useCallback(
+    (axios: AxiosInstance, id: number): Promise<AxiosResponse<Song>> =>
+      axios.get(`${getApiUrl()}/song-info?id=${id}`),
+    [],
+  );
 
-export function useCurrentlyPlayingSongInfo(songId: number | null): Song | null {
-  const [songInfo, setSongInfo] = useState<Song | null>(null);
-  const handleResponse = useCallback((res: Song) => {
-    setSongInfo(res);
-  }, []);
+  const [onFetch, response, , cancelRequest] = useRequestCallback<number, Song>({ sendRequest });
 
-  const query = useMemo<SongInfoQuery>(() => (songId ? { id: songId } : { id: 0 }), [songId]);
+  useEffect(() => {
+    if (state.player.songId) {
+      if (state.player.songId === state.songInfo?.id) {
+        cancelRequest.current?.();
+      } else {
+        onFetch(state.player.songId);
+      }
+    } else if (state.songInfo?.id) {
+      cancelRequest.current?.();
+      dispatch(songInfoFetched(null));
+    }
+  }, [dispatch, state.player.songId, state.songInfo?.id, onFetch, cancelRequest]);
 
-  useCancellableRequest<SongInfoQuery, Song>({
-    query,
-    pause: !songId,
-    sendRequest: sendSongInfoRequest,
-    handleResponse,
-  });
-  return songInfo;
+  useEffect(() => {
+    if (response?.id === state.player.songId) {
+      dispatch(songInfoFetched(response));
+    }
+  }, [dispatch, response, state.player.songId]);
 }

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

@@ -12,9 +12,11 @@ export const Keys = {
   pageUp: 'PageUp',
   '1': '1',
   '2': '2',
+  B: 'b',
   C: 'c',
   J: 'j',
   K: 'k',
+  Z: 'z',
 };
 
 const availableKeys = Object.values(Keys);

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

@@ -8,8 +8,10 @@ import {
   nameSet,
   playPaused,
   seeked,
+  songInfoFetched,
   stateSet,
 } from '../actions';
+import { Song } from '../types';
 import { MusicPlayer } from '../types/state';
 import { globalReducer, initialState, nullPlayer } from './reducer';
 import { GlobalState } from './types';
@@ -496,4 +498,37 @@ describe(globalReducer.name, () => {
       });
     });
   });
+
+  describe(ActionTypeLocal.SongInfoFetched, () => {
+    const song: Song = {
+      id: 123,
+      track: 17,
+      title: 'Some song',
+      artist: 'Some artist',
+      album: 'Some album',
+      time: 214,
+    };
+
+    const action = songInfoFetched(song);
+
+    it('should set the song info in state', () => {
+      expect.assertions(1);
+      const result = globalReducer(initialState, action);
+      expect(result.songInfo).toStrictEqual<Song>(song);
+    });
+
+    describe('when set to replace the current song', () => {
+      const actionReplace = songInfoFetched(song, true);
+
+      it('should play the given song from the start', () => {
+        expect.assertions(4);
+        const result = globalReducer(initialState, actionReplace);
+
+        expect(result.songInfo).toStrictEqual<Song>(song);
+        expect(result.player.playing).toBe(true);
+        expect(result.player.songId).toBe(song.id);
+        expect(result.player.seekTime).toBe(0);
+      });
+    });
+  });
 });

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

@@ -1,4 +1,5 @@
 import {
+  ActionSongInfoFetched,
   ActionStateSetLocal,
   ActionStateSetRemote,
   ActionTypeLocal,
@@ -19,6 +20,7 @@ export const nullPlayer: MusicPlayer = {
 
 export const initialState: GlobalState = {
   initialised: false,
+  songInfo: null,
   player: nullPlayer,
   clientList: [],
   myClientName: '',
@@ -57,6 +59,22 @@ function onLocalStateSet(state: GlobalState, action: ActionStateSetLocal): Globa
   return state;
 }
 
+function onSongFetched(state: GlobalState, action: ActionSongInfoFetched): GlobalState {
+  const nextState: GlobalState = { ...state, songInfo: action.payload.song };
+  if (!action.payload.replace) {
+    return nextState;
+  }
+  return {
+    ...nextState,
+    player: {
+      ...state.player,
+      playing: !!action.payload.song,
+      songId: action.payload.song?.id ?? null,
+      seekTime: 0,
+    },
+  };
+}
+
 export function globalReducer(state: GlobalState, action: AnyAction): GlobalState {
   switch (action.type) {
     case ActionTypeRemote.StateSet:
@@ -105,6 +123,9 @@ export function globalReducer(state: GlobalState, action: AnyAction): GlobalStat
       }
       return { ...state, player: { ...state.player, playing: !state.player.playing } };
 
+    case ActionTypeLocal.SongInfoFetched:
+      return onSongFetched(state, action);
+
     default:
       return state;
   }

+ 2 - 0
gmus-web/src/reducer/types.ts

@@ -1,8 +1,10 @@
+import { Song } from '../types';
 import { Member, MusicPlayer } from '../types/state';
 
 export type GlobalState = {
   initialised: boolean;
   player: MusicPlayer;
+  songInfo: Song | null;
   clientList: Member[];
   myClientName: string;
 };

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

@@ -6,3 +6,7 @@ export type Song = {
   album: string;
   time: number;
 };
+
+export type NullSong = { id: 0 };
+
+export const songExists = (song: Song | NullSong): song is Song => !!song.id;