diff options
| author | Florian Fischer <florian.fischer@muhq.space> | 2022-07-21 00:44:31 +0200 |
|---|---|---|
| committer | Florian Fischer <florian.fischer@muhq.space> | 2022-07-21 00:44:31 +0200 |
| commit | 318fd2fda65e6a0a329a5fad5be3f81d54ccacff (patch) | |
| tree | 936fdce042372b3a97a3256e560af79b773868a5 | |
| download | WRMS-318fd2fda65e6a0a329a5fad5be3f81d54ccacff.tar.gz WRMS-318fd2fda65e6a0a329a5fad5be3f81d54ccacff.zip | |
initial WIP commit
| -rw-r--r-- | .editorconfig | 16 | ||||
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | go.mod | 20 | ||||
| -rw-r--r-- | go.sum | 70 | ||||
| -rw-r--r-- | main.go | 162 | ||||
| -rw-r--r-- | player.go | 35 | ||||
| -rw-r--r-- | playlist.go | 48 | ||||
| -rw-r--r-- | spotify.go | 77 | ||||
| -rw-r--r-- | web/client.html | 211 | ||||
| -rw-r--r-- | wrms.go | 172 |
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 @@ -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 +) @@ -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= @@ -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> @@ -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 + } +} |
