aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2022-07-21 00:44:31 +0200
committerFlorian Fischer <florian.fischer@muhq.space>2022-07-21 00:44:31 +0200
commit318fd2fda65e6a0a329a5fad5be3f81d54ccacff (patch)
tree936fdce042372b3a97a3256e560af79b773868a5
downloadWRMS-318fd2fda65e6a0a329a5fad5be3f81d54ccacff.tar.gz
WRMS-318fd2fda65e6a0a329a5fad5be3f81d54ccacff.zip
initial WIP commit
-rw-r--r--.editorconfig16
-rw-r--r--.gitignore1
-rw-r--r--go.mod20
-rw-r--r--go.sum70
-rw-r--r--main.go162
-rw-r--r--player.go35
-rw-r--r--playlist.go48
-rw-r--r--spotify.go77
-rw-r--r--web/client.html211
-rw-r--r--wrms.go172
10 files changed, 812 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..87ba987
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,16 @@
+# top-most EditorConfig file
+root = true
+
+[*]
+charset = utf-8
+trim_trailing_whitespace = true
+end_of_line = lf
+insert_final_newline = true
+
+[*.go]
+indent_style = tab
+indent_size = 2
+
+[*.{html,cs,js}]
+indent_style = space
+indent_size = 2
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9490ce8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+wrms
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..1e70a32
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,20 @@
+module muhq.space/wrms
+
+go 1.18
+
+require (
+ github.com/badfortrains/mdns v0.0.0-20160325001438-447166384f51 // indirect
+ github.com/bitly/go-simplejson v0.5.0 // indirect
+ github.com/golang/protobuf v1.5.0 // indirect
+ github.com/google/uuid v1.3.0 // indirect
+ github.com/klauspost/compress v1.10.3 // indirect
+ github.com/librespot-org/librespot-golang v0.0.0-20220325184705-31669e5a889f // indirect
+ github.com/miekg/dns v1.1.8 // indirect
+ github.com/xlab/portaudio-go v0.0.0-20170905165025-132d041879db // indirect
+ github.com/xlab/vorbis-go v0.0.0-20210911202351-b5b85f1ec645 // indirect
+ golang.org/x/crypto v0.0.0-20190418165655-df01cb2cc480 // indirect
+ golang.org/x/net v0.0.0-20190420063019-afa5a82059c6 // indirect
+ golang.org/x/sys v0.0.0-20200116001909-b77594299b42 // indirect
+ google.golang.org/protobuf v1.27.1 // indirect
+ nhooyr.io/websocket v1.8.7 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..fdd34ea
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,70 @@
+github.com/badfortrains/mdns v0.0.0-20160325001438-447166384f51 h1:b6+GdpGhuzxPETrVLwY77iq0L/BqLpcPRmaLNW+SoRY=
+github.com/badfortrains/mdns v0.0.0-20160325001438-447166384f51/go.mod h1:qHRkxMBkkpAWD2poeBYQt6O5IS4dE6w1Cr4Z8Q3feXI=
+github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
+github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
+github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
+github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
+github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
+github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
+github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
+github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8=
+github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
+github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
+github.com/librespot-org/librespot-golang v0.0.0-20220325184705-31669e5a889f h1:tTMPsyVClxxV+CnlLqzgxTTTFM6J7i//rSejN1Md0b0=
+github.com/librespot-org/librespot-golang v0.0.0-20220325184705-31669e5a889f/go.mod h1:LeHPXRci2Bg6RBmw8BDEFH5nfb8oGU6mll+qTH8UIzw=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/miekg/dns v1.1.8 h1:1QYRAKU3lN5cRfLCkPU08hwvLJFhvjP6MqNMmQz6ZVI=
+github.com/miekg/dns v1.1.8/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
+github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
+github.com/xlab/portaudio-go v0.0.0-20170905165025-132d041879db h1:sSIQlvfIWUHLDhEWUL2K2CeYv9CDksC00VxuxPUe4lw=
+github.com/xlab/portaudio-go v0.0.0-20170905165025-132d041879db/go.mod h1:r57mRacDQMS6Fz8ubv1nE8zZ0DbQ/sY0NkCmdxeUXmY=
+github.com/xlab/vorbis-go v0.0.0-20190125051917-087364aef51d/go.mod h1:AMqfx3jFwPqem3u8mF2lsRodZs30jG/Mag5HZ3mB3sA=
+github.com/xlab/vorbis-go v0.0.0-20210911202351-b5b85f1ec645 h1:lYg/+vV/Fd5WM1+Ptg54Am3y4mDXaMSrT+mKUHV5uVc=
+github.com/xlab/vorbis-go v0.0.0-20210911202351-b5b85f1ec645/go.mod h1:AMqfx3jFwPqem3u8mF2lsRodZs30jG/Mag5HZ3mB3sA=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190418165655-df01cb2cc480 h1:O5YqonU5IWby+w98jVUG9h7zlCWCcH4RHyPVReBmhzk=
+golang.org/x/crypto v0.0.0-20190418165655-df01cb2cc480/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
+golang.org/x/net v0.0.0-20190420063019-afa5a82059c6 h1:HdqqaWmYAUI7/dmByKKEw+yxDksGSo+9GjkUc9Zp34E=
+golang.org/x/net v0.0.0-20190420063019-afa5a82059c6/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
+google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
+nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..e90dcaf
--- /dev/null
+++ b/main.go
@@ -0,0 +1,162 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+
+ "github.com/google/uuid"
+
+ "nhooyr.io/websocket"
+)
+
+var wrms = NewWrms()
+
+func landingPage(w http.ResponseWriter, r *http.Request) {
+ if _, err := r.Cookie("UUID"); err != nil {
+ http.SetCookie(w, &http.Cookie{Name: "UUID", Value: uuid.NewString()})
+ }
+
+ t, err := template.ParseFiles("web/client.html")
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ t.Execute(w, "")
+}
+
+func searchHandler(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, "Searching for")
+}
+
+func genericVoteHandler(w http.ResponseWriter, r *http.Request, vote string) {
+ uuidCookie, err := r.Cookie("UUID")
+ if err != nil {
+ http.Error(w, "No connection ID cookie set", http.StatusUnauthorized)
+ return
+ }
+ connId := uuidCookie.Value
+
+ songId := r.URL.Query().Get("song")
+ log.Println("%s song %s via url %s", vote, songId, r.URL)
+ wrms.AdjustSongWeight(connId, songId, vote)
+}
+
+func upHandler(w http.ResponseWriter, r *http.Request) {
+ genericVoteHandler(w, r, "up")
+}
+
+func downHandler(w http.ResponseWriter, r *http.Request) {
+ genericVoteHandler(w, r, "down")
+}
+
+func unvoteHandler(w http.ResponseWriter, r *http.Request) {
+ genericVoteHandler(w, r, "unvote")
+}
+
+func addHandler(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, "Added")
+}
+
+func playPauseHandler(w http.ResponseWriter, r *http.Request) {
+ wrms.PlayPause()
+}
+
+func wsEndpoint(w http.ResponseWriter, r *http.Request) {
+ c, err := websocket.Accept(w, r, nil)
+ if err != nil {
+ log.Println("%v", err)
+ return
+ }
+
+ ctx, cancel := context.WithCancel(r.Context())
+
+ ctx = c.CloseRead(ctx)
+
+ uuidCookie, err := r.Cookie("UUID")
+ if err != nil {
+ log.Fatal("websocket connection has no set UUID cookie")
+ }
+
+ id, err := uuid.Parse(uuidCookie.Value)
+ if err != nil {
+ log.Fatal("Invalid UUID set in cookie")
+ }
+
+ defer func() {
+ log.Println("cancel context of connection", id)
+ cancel()
+ }()
+
+ conn := Connection{id, make(chan Event), c}
+ wrms.Connections = append(wrms.Connections, conn)
+
+ log.Println("New websocket connection with id", id)
+
+ initialCmds := [][]byte{}
+ if wrms.Playing {
+ data, err := json.Marshal(Event{"play", []Song{*wrms.CurrentSong}})
+ if err != nil {
+ log.Println(err)
+ return
+ }
+ initialCmds = append(initialCmds, data)
+ }
+
+ data, err := json.Marshal(Event{"add", wrms.Songs})
+ if err != nil {
+ log.Println(err)
+ return
+ }
+ initialCmds = append(initialCmds, data)
+
+ for _, data := range initialCmds {
+ log.Println("Sending initial cmd", string(data))
+ err = c.Write(ctx, websocket.MessageText, data)
+ if err != nil {
+ log.Println(err)
+ return
+ }
+ }
+
+ for ev := range conn.Events {
+ data, err := json.Marshal(ev)
+ if err != nil {
+ log.Println(err)
+ return
+ }
+
+ log.Println(fmt.Sprintf("Sending ev %s to %s", string(data), id))
+ err = c.Write(ctx, websocket.MessageText, data)
+ if err != nil {
+ log.Println(err)
+ return
+ }
+ }
+}
+
+func setupRoutes() {
+ http.HandleFunc("/", landingPage)
+ http.HandleFunc("/search", searchHandler)
+ http.HandleFunc("/up", upHandler)
+ http.HandleFunc("/down", downHandler)
+ http.HandleFunc("/unvote", unvoteHandler)
+ http.HandleFunc("/add", addHandler)
+ http.HandleFunc("/playpause", playPauseHandler)
+ http.HandleFunc("/ws", wsEndpoint)
+}
+
+func main() {
+ setupRoutes()
+
+ wrms.AddSong(NewSong("Lala", "SNFMT", "local"))
+ wrms.AddSong(NewSong("Hobelbank", "MC Wankwichtel", "local"))
+
+ log.Println(wrms)
+ defer wrms.Close()
+ log.Fatal(http.ListenAndServe(":8080", nil))
+}
diff --git a/player.go b/player.go
new file mode 100644
index 0000000..73b5441
--- /dev/null
+++ b/player.go
@@ -0,0 +1,35 @@
+package main
+
+import (
+ "log"
+)
+
+type Backend interface {
+ Play(song Song)
+ Pause()
+ Search(keyword string) []Song
+}
+
+type Player struct {
+ Backends map[string]Backend
+}
+
+func NewPlayer() Player {
+ available_backends := map[string]Backend{}
+
+ spotify, err := NewSpotify()
+ if err != nil {
+ // log.Fatalln("Error during initialization of the spotify backend:", err.Error())
+ log.Println("Error during initialization of the spotify backend:", err.Error())
+ }
+ available_backends["spotify"] = spotify
+
+ return Player{available_backends}
+}
+
+func (player *Player) Play(song Song) {
+ player.Backends[song.Source].Play(song)
+}
+
+func (player *Player) Pause() {
+}
diff --git a/playlist.go b/playlist.go
new file mode 100644
index 0000000..b37ab3d
--- /dev/null
+++ b/playlist.go
@@ -0,0 +1,48 @@
+package main
+
+import (
+ "container/heap"
+)
+
+type Playlist []*Song
+
+func (pl Playlist) Len() int { return len(pl) }
+
+func (pl Playlist) Less(i, j int) bool {
+ return pl[i].Weight > pl[j].Weight
+}
+
+func (pl Playlist) Swap(i, j int) {
+ pl[i], pl[j] = pl[j], pl[i]
+ pl[i].index = i
+ pl[j].index = j
+}
+
+func (pl *Playlist) Push(x any) {
+ n := len(*pl)
+ song := x.(*Song)
+ song.index = n
+ *pl = append(*pl, song)
+}
+
+func (pl *Playlist) Pop() any {
+ old := *pl
+ n := len(old)
+ s := old[n-1]
+ old[n-1] = nil // avoid memory leak
+ s.index = -1 // for safety
+ *pl = old[0 : n-1]
+ return s
+}
+
+func (pl *Playlist) PopSong() *Song {
+ return heap.Pop(pl).(*Song)
+}
+
+func (pl *Playlist) Add(s *Song) {
+ heap.Push(pl, s)
+}
+
+func (pl *Playlist) Adjust(s *Song) {
+ heap.Fix(pl, s.index)
+}
diff --git a/spotify.go b/spotify.go
new file mode 100644
index 0000000..a813254
--- /dev/null
+++ b/spotify.go
@@ -0,0 +1,77 @@
+package main
+
+// The spotify backend is based on the librespot-golang code released under MIT
+// license.
+// Authors of the librespot-golang code are:
+// Copyright (c) 2015 Paul Lietar
+// Copyright (c) 2015 badfortrains
+// Copyright (c) 2018 Guillaume "xplodwild" Lesniak
+
+import (
+ "errors"
+ "os"
+
+ // "github.com/librespot-org/librespot-golang/Spotify"
+ "github.com/librespot-org/librespot-golang/librespot"
+ "github.com/librespot-org/librespot-golang/librespot/core"
+ // "github.com/librespot-org/librespot-golang/librespot/utils"
+ "github.com/xlab/portaudio-go/portaudio"
+ // "github.com/xlab/vorbis-go/decoder"
+)
+
+const (
+ deviceName = "wrms"
+ // The number of samples per channel in the decoded audio
+ samplesPerChannel = 2048
+ // The samples bit depth
+ bitDepth = 16
+ // The samples format
+ sampleFormat = portaudio.PaFloat32
+)
+
+type SpotifyBackend struct {
+ session *core.Session
+}
+
+func NewSpotify() (*SpotifyBackend, error) {
+ spotify := SpotifyBackend{}
+
+ if err := portaudio.Initialize(); paError(err) {
+ return nil, errors.New(paErrorText(err))
+ }
+
+ var err error
+
+ username := os.Getenv("WRMS_SPOTIFY_USER")
+ password := os.Getenv("WRMS_SPOTIFY_PASSWORD")
+ if username == "" || password == "" {
+ return nil, errors.New("User and password for spotify must be provided")
+ }
+
+ spotify.session, err = librespot.Login(username, password, deviceName)
+
+ if err != nil {
+ return nil, err
+ }
+
+ return &spotify, nil
+}
+
+func (spotify *SpotifyBackend) Play(song Song) {
+}
+
+func (spotify *SpotifyBackend) Pause() {
+}
+
+func (spotify *SpotifyBackend) Search(keyword string) []Song {
+ return []Song{}
+}
+
+// PortAudio helpers
+func paError(err portaudio.Error) bool {
+ return portaudio.ErrorCode(err) != portaudio.PaNoError
+}
+
+func paErrorText(err portaudio.Error) string {
+ return "PortAudio error: " + portaudio.GetErrorText(err)
+}
diff --git a/web/client.html b/web/client.html
new file mode 100644
index 0000000..d7f8db2
--- /dev/null
+++ b/web/client.html
@@ -0,0 +1,211 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>WRMS</title>
+
+ <style>
+ .vote {
+ display: inline-block;
+ cursor: pointer;
+ color: #687074
+ }
+
+ .vote.on {
+ color: #f48024
+ }
+ </style>
+
+ <script>
+ /*
+ @licstart The following is the entire license notice for the
+ JavaScript code in this page.
+
+ Copyright (C) 2022 Florian Fischer
+
+ The JavaScript code in this page is free software: you can
+ redistribute it and/or modify it under the terms of the GNU
+ General Public License (GNU GPL) as published by the Free Software
+ Foundation, either version 3 of the License, or (at your option)
+ any later version. The code is distributed WITHOUT ANY WARRANTY;
+ without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
+
+ As additional permission under GNU GPL version 3 section 7, you
+ may distribute non-source (e.g., minimized or compacted) forms of
+ that code without the copy of the GNU GPL normally required by
+ section 4, provided you include this license notice and a URL
+ through which recipients can access the Corresponding Source.
+
+
+ @licend The above is the entire license notice
+ for the JavaScript code in this page.
+ */
+ </script>
+
+ <script>
+ var HttpClient = function() {
+ this.get = function(url, callback) {
+ var request = new XMLHttpRequest();
+ request.onreadystatechange = function() {
+ if (request.readyState == 4 && request.status == 200)
+ callback(request.responseText);
+ }
+
+ request.open("GET", url, true);
+ request.send(null);
+ }
+ }
+
+ let songs = [];
+ let playing = {};
+ let votes = new Map();
+
+ function renderPlaylist() {
+ playlist = document.getElementById("playlist");
+ playlist.innerHTML = "";
+
+ songs.sort(function(a, b) {return b.weight - a.weight});
+
+ for (const song of songs) {
+ var listItem = document.createElement("li");
+
+ const btnHTML = "<svg width='36' height='36'><path d='M2 10h32L18 26 2 10z' fill='currentColor'></path></svg>";
+ var upvoteBtn = document.createElement("span");
+ var downvoteBtn = document.createElement("span");
+
+ upvoteBtn.innerHTML = downvoteBtn.innerHTML = btnHTML;
+ upvoteBtn.className = downvoteBtn.className = "vote";
+ upvoteBtn.style.transform = 'rotate(180deg)';
+
+ if (votes.has(song.id)) {
+ if (votes.get(song.id) == "up")
+ upvoteBtn.classList.toggle("on");
+ else
+ downvoteBtn.classList.toggle("on");
+ }
+
+ function onVote(event) {
+ const btn = event.currentTarget;
+ btn.classList.toggle("on");
+ const isUpvoteBtn = btn == upvoteBtn;
+ const isVote = btn.classList.contains("on");
+ var url = "";
+
+ if (isVote) {
+ if (isUpvoteBtn) {
+ downvoteBtn.classList.remove("on")
+ votes.set(song.id, "up")
+ } else {
+ upvoteBtn.classList.remove("on")
+ votes.set(song.id, "down")
+ }
+
+ url = "/" + (isUpvoteBtn ? "up" : "down") + "?song=" + song.id;
+ } else {
+ votes.delete(song.id);
+ url = "/unvote?song=" + song.id;
+ }
+
+ console.log("Get " + url);
+ new HttpClient().get(url, console.log);
+ }
+ upvoteBtn.addEventListener("click", onVote);
+ downvoteBtn.addEventListener("click", onVote);
+
+ listItem.appendChild(upvoteBtn);
+ listItem.appendChild(downvoteBtn);
+
+ const itemValue = document.createTextNode(song.artist + " - " + song.title);
+ listItem.appendChild(itemValue);
+ playlist.appendChild(listItem);
+ }
+ }
+
+ function handleAdd(added) {
+ songs = songs.concat(added);
+ renderPlaylist();
+ }
+
+ function handleUpdate(updated) {
+ for (const updated_song of updated) {
+ songs[songs.findIndex(function(s) {return s.id == updated_song.id})] = updated_song;
+ }
+ renderPlaylist();
+ }
+
+ function handlePause() {
+ document.getElementById("ppbutton").innerHTML = 'Play';
+ }
+
+ function handlePlay(currentSong) {
+ document.getElementById("ppbutton").innerHTML = 'Pause';
+
+ idx = -1;
+ songs.forEach(function(s, i, a) { if (s.id == currentSong.id) idx = i; });
+ if (idx != -1) {
+ songs.splice(idx, 1);
+ renderPlaylist();
+ }
+
+ const songLabel = document.createTextNode(currentSong.artist + " - " + currentSong.title);
+
+ playing = document.getElementById("playing");
+ playing.innerHTML = "";
+ playing.appendChild(songLabel);
+ }
+
+ let socket = new WebSocket("ws://localhost:8080/ws");
+ console.log("Attempting Connection...");
+
+ socket.onopen = () => {
+ console.log("Successfully Connected");
+ };
+
+ socket.onclose = event => {
+ console.log("Socket Closed Connection: ", event);
+ };
+
+ socket.onmessage = message => {
+ console.log("Socket Received Message: ", message);
+ const cmd = JSON.parse(message.data);
+ switch (cmd.cmd) {
+ case "add":
+ handleAdd(cmd.songs)
+ break;
+ case "update":
+ handleUpdate(cmd.songs)
+ break;
+ case "pause":
+ handlePause()
+ break;
+ case "play":
+ handlePlay(cmd.songs[0])
+ break;
+ }
+ };
+
+ socket.onerror = error => {
+ console.log("Socket Error: ", error);
+ };
+
+ window.onload = function() {
+ document.getElementById("ppbutton").addEventListener("click", function() {
+ new HttpClient().get("/playpause", console.log);
+ });
+ }
+ </script>
+ </head>
+
+ <body>
+ <h2>Playing</h2>
+ <p id='playing'></p>
+ <div id='controls'>
+ <button id="ppbutton" form='ppform'>Play</button>
+ </div>
+
+ <h2>Playlist</h2>
+ <ul id='playlist'></ul>
+ </body>
+</html>
diff --git a/wrms.go b/wrms.go
new file mode 100644
index 0000000..b648744
--- /dev/null
+++ b/wrms.go
@@ -0,0 +1,172 @@
+package main
+
+import (
+ "crypto/sha1"
+ "encoding/base64"
+ "fmt"
+ "log"
+
+ "github.com/google/uuid"
+
+ "nhooyr.io/websocket"
+)
+
+type Song struct {
+ Title string `json:"title"`
+ Artist string `json:"artist"`
+ Source string `json:"source"`
+ Id string `json:"id"`
+ Weight int `json:"weight"`
+ index int `json:"-"` // used by heap.Interface
+ Upvotes map[string]struct{} `json:"-"`
+ Downvotes map[string]struct{} `json:"-"`
+}
+
+func NewSong(title string, artist string, source string) Song {
+ s := Song{title, artist, source, "", 0, 0, map[string]struct{}{}, map[string]struct{}{}}
+ h := sha1.New()
+ h.Write([]byte(title))
+ h.Write([]byte(artist))
+ h.Write([]byte(source))
+ s.Id = base64.URLEncoding.EncodeToString(h.Sum(nil))
+ return s
+}
+
+type Event struct {
+ Event string `json:"cmd"`
+ Songs []Song `json:"songs"`
+}
+
+type Connection struct {
+ Id uuid.UUID
+ Events chan Event
+ C *websocket.Conn
+}
+
+func (c *Connection) Close() {
+ log.Println("Closing connection %s", c.Id)
+ close(c.Events)
+ c.C.Close(websocket.StatusNormalClosure, "")
+}
+
+type Wrms struct {
+ Connections []Connection
+ Songs []Song
+ Queue Playlist
+ CurrentSong *Song
+ Player Player
+ Playing bool
+}
+
+func NewWrms() Wrms {
+ wrms := Wrms{}
+ wrms.Player = NewPlayer()
+ return wrms
+}
+
+func (wrms *Wrms) Close() {
+ log.Println("Closing WRMS")
+ for _, conn := range wrms.Connections {
+ conn.Close()
+ }
+}
+
+func (wrms *Wrms) Broadcast(cmd Event) {
+ for i := 0; i < len(wrms.Connections); i++ {
+ wrms.Connections[i].Events <- cmd
+ }
+}
+
+func (wrms *Wrms) AddSong(song Song) {
+ wrms.Songs = append(wrms.Songs, song)
+ wrms.Queue.Add(&wrms.Songs[len(wrms.Songs) - 1])
+}
+
+func (wrms *Wrms) Next() {
+ wrms.CurrentSong = wrms.Queue.PopSong()
+ for i, s := range wrms.Songs {
+ if s.Id == wrms.CurrentSong.Id {
+ wrms.Songs[i] = wrms.Songs[len(wrms.Songs) - 1]
+ wrms.Songs = wrms.Songs[:len(wrms.Songs) - 1]
+ break
+ }
+ }
+}
+
+func (wrms *Wrms) PlayPause() {
+ var ev Event
+ if !wrms.Playing {
+ if wrms.CurrentSong == nil {
+ wrms.Next()
+ }
+ ev = Event{"play", []Song{*wrms.CurrentSong}}
+ } else {
+ ev = Event{"pause", []Song{}}
+ }
+
+ wrms.Playing = !wrms.Playing
+
+ wrms.Broadcast(ev)
+}
+
+func (wrms *Wrms) AdjustSongWeight(connId string, songId string, vote string) {
+ for i := 0; i < len(wrms.Songs); i++ {
+ s := &wrms.Songs[i]
+ if s.Id != songId {
+ continue
+ }
+
+ switch vote {
+ case "up":
+ if _, ok := s.Upvotes[connId]; ok {
+ log.Println(fmt.Sprintf("Double upvote of song %s by connections %s", songId, connId))
+ return
+ }
+
+ if _, ok := s.Downvotes[connId]; ok {
+ delete(s.Downvotes, connId)
+ s.Weight += 2
+ } else {
+ s.Weight += 1
+ }
+
+ s.Upvotes[connId] = struct{}{}
+
+ case "down":
+ if _, ok := s.Downvotes[connId]; ok {
+ log.Println(fmt.Sprintf("Double downvote of song %s by connections %s", songId, connId))
+ return
+ }
+
+ if _, ok := s.Upvotes[connId]; ok {
+ delete(s.Upvotes, connId)
+ s.Weight -= 2
+ } else {
+ s.Weight -= 1
+ }
+
+ s.Downvotes[connId] = struct{}{}
+
+ case "unvote":
+ if _, ok := s.Downvotes[connId]; ok {
+ delete(s.Downvotes, connId)
+ s.Weight += 1
+ }
+
+ if _, ok := s.Upvotes[connId]; ok {
+ delete(s.Upvotes, connId)
+ s.Weight -= 1
+ } else {
+ log.Println(fmt.Sprintf("Double downvote of song %s by connections %s", songId, connId))
+ return
+ }
+
+ default:
+ log.Fatal("invalid vote")
+ }
+
+ wrms.Queue.Adjust(s)
+ wrms.Broadcast(Event{"update", []Song{*s}})
+ break
+ }
+}