diff --git a/internal/playback/playlist_navigation.go b/internal/playback/playlist_navigation.go new file mode 100644 index 0000000..6586438 --- /dev/null +++ b/internal/playback/playlist_navigation.go @@ -0,0 +1,93 @@ +package playback + +import "errors" + +type PlaylistCommandKind uint8 + +const ( + PlaylistSelect PlaylistCommandKind = iota + 1 + PlaylistNext + PlaylistPrevious +) + +type PlaylistCommand struct { + Kind PlaylistCommandKind + Index int +} + +type PlaylistState struct { + CurrentIndex int + HasSelection bool +} + +var ( + ErrPlaylistEmpty = errors.New("playlist is empty") + ErrPlaylistIndexOutOfRange = errors.New("playlist index is out of range") + ErrUnknownPlaylistCommand = errors.New("unknown playlist command") +) + +func ApplyPlaylistCommand( + playlist Playlist, + current PlaylistState, + command PlaylistCommand, +) (PlaylistState, error) { + if err := playlist.Validate(); err != nil { + return current, err + } + if len(playlist.Entries) == 0 { + return current, ErrPlaylistEmpty + } + + lastIndex := len(playlist.Entries) - 1 + switch command.Kind { + case PlaylistSelect: + if command.Index < 0 || command.Index > lastIndex { + return current, ErrPlaylistIndexOutOfRange + } + return PlaylistState{CurrentIndex: command.Index, HasSelection: true}, nil + + case PlaylistNext: + if !current.HasSelection { + return PlaylistState{CurrentIndex: 0, HasSelection: true}, nil + } + if current.CurrentIndex < 0 || current.CurrentIndex > lastIndex { + return current, ErrPlaylistIndexOutOfRange + } + if current.CurrentIndex == lastIndex { + if playlist.Loop { + return PlaylistState{CurrentIndex: 0, HasSelection: true}, nil + } + return current, nil + } + return PlaylistState{CurrentIndex: current.CurrentIndex + 1, HasSelection: true}, nil + + case PlaylistPrevious: + if !current.HasSelection { + index := 0 + if playlist.Loop { + index = lastIndex + } + return PlaylistState{CurrentIndex: index, HasSelection: true}, nil + } + if current.CurrentIndex < 0 || current.CurrentIndex > lastIndex { + return current, ErrPlaylistIndexOutOfRange + } + if current.CurrentIndex == 0 { + if playlist.Loop { + return PlaylistState{CurrentIndex: lastIndex, HasSelection: true}, nil + } + return current, nil + } + return PlaylistState{CurrentIndex: current.CurrentIndex - 1, HasSelection: true}, nil + + default: + return current, ErrUnknownPlaylistCommand + } +} + +func (s PlaylistState) Entry(playlist Playlist) (PlaylistEntry, bool) { + if !s.HasSelection || s.CurrentIndex < 0 || s.CurrentIndex >= len(playlist.Entries) { + return PlaylistEntry{}, false + } + return playlist.Entries[s.CurrentIndex], true +} diff --git a/internal/playback/playlist_navigation_test.go b/internal/playback/playlist_navigation_test.go new file mode 100644 index 0000000..ad60d05 --- /dev/null +++ b/internal/playback/playlist_navigation_test.go @@ -0,0 +1,191 @@ +package playback + +import ( + "errors" + "testing" +) + +func navigationPlaylist(loop bool) Playlist { + return Playlist{ + Entries: []PlaylistEntry{ + {Name: "first", Video: PlaylistFeed{Domain: "domain", UUID: "video-1"}}, + {Name: "second", Audio: PlaylistFeed{Domain: "domain", UUID: "audio-2"}}, + {Name: "third", Video: PlaylistFeed{Domain: "domain", UUID: "video-3"}}, + }, + Loop: loop, + } +} + +func TestApplyPlaylistCommandSelect(t *testing.T) { + current := PlaylistState{CurrentIndex: 1, HasSelection: true} + + got, err := ApplyPlaylistCommand( + navigationPlaylist(false), + current, + PlaylistCommand{Kind: PlaylistSelect, Index: 2}, + ) + if err != nil { + t.Fatalf("ApplyPlaylistCommand() error = %v", err) + } + want := PlaylistState{CurrentIndex: 2, HasSelection: true} + if got != want { + t.Fatalf("ApplyPlaylistCommand() = %#v, want %#v", got, want) + } +} + +func TestApplyPlaylistCommandWithoutSelection(t *testing.T) { + tests := []struct { + name string + loop bool + command PlaylistCommandKind + want int + }{ + {name: "next", command: PlaylistNext, want: 0}, + {name: "previous without loop", command: PlaylistPrevious, want: 0}, + {name: "previous with loop", loop: true, command: PlaylistPrevious, want: 2}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := ApplyPlaylistCommand( + navigationPlaylist(test.loop), + PlaylistState{}, + PlaylistCommand{Kind: test.command}, + ) + if err != nil { + t.Fatalf("ApplyPlaylistCommand() error = %v", err) + } + want := PlaylistState{CurrentIndex: test.want, HasSelection: true} + if got != want { + t.Fatalf("ApplyPlaylistCommand() = %#v, want %#v", got, want) + } + }) + } +} + +func TestApplyPlaylistCommandNavigation(t *testing.T) { + tests := []struct { + name string + loop bool + current int + command PlaylistCommandKind + want int + }{ + {name: "next", current: 1, command: PlaylistNext, want: 2}, + {name: "previous", current: 1, command: PlaylistPrevious, want: 0}, + {name: "next stops at end", current: 2, command: PlaylistNext, want: 2}, + {name: "previous stops at beginning", current: 0, command: PlaylistPrevious, want: 0}, + {name: "next wraps", loop: true, current: 2, command: PlaylistNext, want: 0}, + {name: "previous wraps", loop: true, current: 0, command: PlaylistPrevious, want: 2}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + current := PlaylistState{CurrentIndex: test.current, HasSelection: true} + got, err := ApplyPlaylistCommand( + navigationPlaylist(test.loop), + current, + PlaylistCommand{Kind: test.command}, + ) + if err != nil { + t.Fatalf("ApplyPlaylistCommand() error = %v", err) + } + want := PlaylistState{CurrentIndex: test.want, HasSelection: true} + if got != want { + t.Fatalf("ApplyPlaylistCommand() = %#v, want %#v", got, want) + } + }) + } +} + +func TestApplyPlaylistCommandErrorsLeaveStateUnchanged(t *testing.T) { + current := PlaylistState{CurrentIndex: 1, HasSelection: true} + tests := []struct { + name string + playlist Playlist + command PlaylistCommand + wantErr error + }{ + { + name: "empty playlist", + playlist: Playlist{}, + command: PlaylistCommand{Kind: PlaylistNext}, + wantErr: ErrPlaylistEmpty, + }, + { + name: "negative selection", + playlist: navigationPlaylist(false), + command: PlaylistCommand{Kind: PlaylistSelect, Index: -1}, + wantErr: ErrPlaylistIndexOutOfRange, + }, + { + name: "selection past end", + playlist: navigationPlaylist(false), + command: PlaylistCommand{Kind: PlaylistSelect, Index: 3}, + wantErr: ErrPlaylistIndexOutOfRange, + }, + { + name: "stale current index", + playlist: navigationPlaylist(false), + command: PlaylistCommand{Kind: PlaylistNext}, + wantErr: ErrPlaylistIndexOutOfRange, + }, + { + name: "unknown command", + playlist: navigationPlaylist(false), + command: PlaylistCommand{}, + wantErr: ErrUnknownPlaylistCommand, + }, + { + name: "invalid playlist", + playlist: Playlist{Entries: []PlaylistEntry{ + {}, + }}, + command: PlaylistCommand{Kind: PlaylistNext}, + wantErr: ErrPlaylistEntryEmpty, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + state := current + if test.name == "stale current index" { + state.CurrentIndex = 4 + } + got, err := ApplyPlaylistCommand(test.playlist, state, test.command) + if !errors.Is(err, test.wantErr) { + t.Fatalf("ApplyPlaylistCommand() error = %v, want %v", err, test.wantErr) + } + if got != state { + t.Fatalf("ApplyPlaylistCommand() = %#v, want unchanged %#v", got, state) + } + }) + } +} + +func TestPlaylistStateEntry(t *testing.T) { + playlist := navigationPlaylist(false) + tests := []struct { + name string + state PlaylistState + want string + ok bool + }{ + {name: "no selection", state: PlaylistState{}}, + {name: "selected", state: PlaylistState{CurrentIndex: 1, HasSelection: true}, want: "second", ok: true}, + {name: "negative stale index", state: PlaylistState{CurrentIndex: -1, HasSelection: true}}, + {name: "stale index", state: PlaylistState{CurrentIndex: 3, HasSelection: true}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + entry, ok := test.state.Entry(playlist) + if ok != test.ok { + t.Fatalf("Entry() ok = %v, want %v", ok, test.ok) + } + if entry.Name != test.want { + t.Fatalf("Entry() name = %q, want %q", entry.Name, test.want) + } + }) + } +}