From 7e4333dac70cdb003e71b6805ed4d81f18aa233a Mon Sep 17 00:00:00 2001 From: Simon Garrelou Date: Thu, 8 Dec 2022 22:35:17 +0100 Subject: Music playback working --- cmd/main.go | 2 +- go.mod | 30 ++++++++-- go.sum | 63 ++++++++++++++++++++- music/patch.go | 76 +++++++++++++++++++++++++ music/playqueue.go | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/app.go | 45 +++++++-------- src/footer.go | 9 +-- src/header.go | 47 ++++++++++++++++ src/page_artists.go | 53 ++++++++++++++---- 9 files changed, 430 insertions(+), 53 deletions(-) create mode 100644 music/patch.go create mode 100644 music/playqueue.go create mode 100644 src/header.go diff --git a/cmd/main.go b/cmd/main.go index 53874e5..c1ff827 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,7 +5,7 @@ import ( "fmt" "os" - "git.sixfoisneuf.fr/simon/termsonic/src" + "git.sixfoisneuf.fr/termsonic/src" ) var ( diff --git a/go.mod b/go.mod index 3a9d2e8..f4f3c78 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,35 @@ -module git.sixfoisneuf.fr/simon/termsonic +module git.sixfoisneuf.fr/termsonic go 1.19 require ( - github.com/BurntSushi/toml v1.2.1 // indirect - github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5 // indirect + github.com/BurntSushi/toml v1.2.1 + github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5 + github.com/faiface/beep v1.1.0 + github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1 + github.com/jfbus/httprs v0.0.0-20190827093123-b0af8319bb15 + github.com/rivo/tview v0.0.0-20221128165837-db36428c92d9 +) + +require ( github.com/gdamore/encoding v1.0.0 // indirect - github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1 // indirect + github.com/hajimehoshi/go-mp3 v0.3.0 // indirect + github.com/hajimehoshi/oto v0.7.1 // indirect + github.com/icza/bitio v1.0.0 // indirect + github.com/jfreymuth/oggvorbis v1.0.1 // indirect + github.com/jfreymuth/vorbis v1.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect - github.com/rivo/tview v0.0.0-20221128165837-db36428c92d9 // indirect + github.com/mewkiz/flac v1.0.7 // indirect + github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2 // indirect + github.com/mitchellh/copystructure v1.0.0 // indirect + github.com/mitchellh/reflectwalk v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.2 // indirect - golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 // indirect + golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 // indirect + golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 // indirect + golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 // indirect + golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e // indirect golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d // indirect golang.org/x/text v0.3.7 // indirect ) diff --git a/go.sum b/go.sum index 43494fb..95da911 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,81 @@ github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5 h1:RuuxidatioSKGOiBzL1mTY4X22DQD8weEbS3iRLHnAg= github.com/delucks/go-subsonic v0.0.0-20220915164742-2744002c4be5/go.mod h1:vnbEuj6Z20PLcHB4rrLQAOXGMjtULfMGhRVSFPcSdUo= +github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c= +github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1 h1:QqwPZCwh/k1uYqq6uXSb9TRDhTkfQbO80v8zhnIe5zM= github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1/go.mod h1:Az6Jt+M5idSED2YPGtwnfJV0kXohgdCBPmHGSYc1r04= +github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= +github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= +github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/hajimehoshi/go-mp3 v0.3.0 h1:fTM5DXjp/DL2G74HHAs/aBGiS9Tg7wnp+jkU38bHy4g= +github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= +github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= +github.com/hajimehoshi/oto v0.7.1 h1:I7maFPz5MBCwiutOrz++DLdbr4rTzBsbBuV2VpgU9kk= +github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= +github.com/icza/bitio v1.0.0 h1:squ/m1SHyFeCA6+6Gyol1AxV9nmPPlJFT8c2vKdj3U8= +github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= +github.com/jfbus/httprs v0.0.0-20190827093123-b0af8319bb15 h1:HPqgCwRiChGXITjjipDuTJYVPkAUpM4lp0mfo7ONpjo= +github.com/jfbus/httprs v0.0.0-20190827093123-b0af8319bb15/go.mod h1:hve3GCzwH1IcxgpZ3UN4XKAPSKoIqJhsYF2ZifruodQ= +github.com/jfreymuth/oggvorbis v1.0.1 h1:NT0eXBgE2WHzu6RT/6zcb2H10Kxj6Fm3PccT0LE6bqw= +github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= +github.com/jfreymuth/vorbis v1.0.0 h1:SmDf783s82lIjGZi8EGUUaS7YxPHgRj4ZXW/h7rUi7U= +github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mewkiz/flac v1.0.7 h1:uIXEjnuXqdRaZttmSFM5v5Ukp4U6orrZsnYGGR3yow8= +github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= +github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2 h1:EyTNMdePWaoWsRSGQnXiSoQu0r6RS1eA557AwJhlzHU= +github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA= +github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/rivo/tview v0.0.0-20221128165837-db36428c92d9 h1:ccTgRxA37ypj3q8zB8G4k3xGPfBbIaMwrf3Yw6k50NY= github.com/rivo/tview v0.0.0-20221128165837-db36428c92d9/go.mod h1:YX2wUZOcJGOIycErz2s9KvDaP0jnWwRCirQMPLPpQ+Y= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337 h1:WN9BUFbdyOsSH/XohnWpXOlq9NBD5sGAB2FciQMUEe8= +github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 h1:idBdZTd9UioThJp8KpM/rTSinK/ChZFBE43/WtIy8zg= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 h1:KYGJGHOQy8oSi1fDlSpcZF0+juKwk/hEMv5SiwHogR0= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 h1:vyLBGJPIl9ZYbcQFM2USFmJBK6KI+t+z6jL0lbwjrnc= +golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 h1:46ULzRKLh1CwgRq2dC5SlBzEqqNCi8rreOZnNrbqcIY= -golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e h1:NHvCuwuS43lGnYhten69ZWqi2QOj/CiDNcKbVqwVoew= +golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -26,3 +84,4 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= diff --git a/music/patch.go b/music/patch.go new file mode 100644 index 0000000..aba20ec --- /dev/null +++ b/music/patch.go @@ -0,0 +1,76 @@ +package music + +import ( + "encoding/xml" + "fmt" + "io" + "io/ioutil" + "net/url" + "strings" + + "github.com/delucks/go-subsonic" + "github.com/jfbus/httprs" +) + +// Stream2 patches subsonic.Client.Stream to return a ReadCloser for use with "beep" +func Stream2(s *subsonic.Client, id string, parameters map[string]string) (io.ReadCloser, error) { + params := url.Values{} + params.Add("id", id) + for k, v := range parameters { + params.Add(k, v) + } + response, err := s.Request("GET", "stream", params) + if err != nil { + return nil, err + } + contentType := response.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "text/xml") || strings.HasPrefix(contentType, "application/xml") { + // An error was returned + responseBody, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, err + } + resp := subsonic.Response{} + err = xml.Unmarshal(responseBody, &resp) + if err != nil { + return nil, err + } + if resp.Error != nil { + err = fmt.Errorf("Error #%d: %s\n", resp.Error.Code, resp.Error.Message) + } else { + err = fmt.Errorf("An error occurred: %#v\n", resp) + } + return nil, err + } + + return httprs.NewHttpReadSeeker(response), nil +} + +func Download2(s *subsonic.Client, id string) (io.ReadCloser, error) { + params := url.Values{} + params.Add("id", id) + response, err := s.Request("GET", "download", params) + if err != nil { + return nil, err + } + contentType := response.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "text/xml") || strings.HasPrefix(contentType, "application/xml") { + // An error was returned + responseBody, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, err + } + resp := subsonic.Response{} + err = xml.Unmarshal(responseBody, &resp) + if err != nil { + return nil, err + } + if resp.Error != nil { + err = fmt.Errorf("Error #%d: %s\n", resp.Error.Code, resp.Error.Message) + } else { + err = fmt.Errorf("An error occurred: %#v\n", resp) + } + return nil, err + } + return httprs.NewHttpReadSeeker(response), nil +} diff --git a/music/playqueue.go b/music/playqueue.go new file mode 100644 index 0000000..d6182ed --- /dev/null +++ b/music/playqueue.go @@ -0,0 +1,158 @@ +package music + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/delucks/go-subsonic" + "github.com/faiface/beep" + "github.com/faiface/beep/flac" + "github.com/faiface/beep/mp3" + "github.com/faiface/beep/speaker" + "github.com/faiface/beep/vorbis" +) + +type Queue struct { + songs []*subsonic.Child + isPaused bool + + sub *subsonic.Client + speakerInitialized bool + oldSampleRate beep.SampleRate + onChange func(newSong *subsonic.Child, isPaused bool) +} + +func NewQueue(client *subsonic.Client) *Queue { + return &Queue{ + sub: client, + speakerInitialized: false, + } +} + +func (q *Queue) SetClient(client *subsonic.Client) { + q.Clear() + q.sub = client +} + +func (p *Queue) GetSongs() []*subsonic.Child { + return p.songs +} + +func (q *Queue) Append(s *subsonic.Child) { + q.songs = append(q.songs, s) +} + +func (q *Queue) Clear() { + q.songs = make([]*subsonic.Child, 0) + speaker.Clear() +} + +func (q *Queue) PlaySong(s *subsonic.Child) error { + rc, err := Download2(q.sub, s.ID) + if err != nil { + return err + } + + var ssc beep.StreamSeekCloser + var format beep.Format + + switch filepath.Ext(s.Path) { + case ".mp3": + ssc, format, err = mp3.Decode(rc) + case ".ogg": + fallthrough + case ".oga": + ssc, format, err = vorbis.Decode(rc) + case ".flac": + ssc, format, err = flac.Decode(rc) + default: + return fmt.Errorf("unknown file type: %s", filepath.Ext(s.Path)) + } + + if err != nil { + return err + } + + streamer, err := q.setupSpeaker(ssc, format) + if err != nil { + return err + } + speaker.Clear() + speaker.Play(beep.Seq(streamer, beep.Callback(func() { go q.Next() }))) + + if q.onChange != nil { + q.onChange(s, false) + } + + return nil +} + +func (q *Queue) Play() error { + if len(q.songs) == 0 { + return fmt.Errorf("the queue is empty") + } + + s := q.songs[0] + q.PlaySong(s) + + return nil +} + +func (q *Queue) Next() error { + q.Stop() + + if len(q.songs) == 0 { + return nil + } + + q.songs = q.songs[1:] + + if len(q.songs) == 0 { + if q.onChange != nil { + q.onChange(nil, false) + } + return nil + } + + return q.Play() +} + +func (q *Queue) Stop() { + speaker.Clear() +} + +func (q *Queue) SetOnChangeCallback(f func(newSong *subsonic.Child, isPlaying bool)) { + q.onChange = f +} + +func (q *Queue) TogglePause() { + if q.isPaused { + speaker.Unlock() + } else { + speaker.Lock() + } + + q.isPaused = !q.isPaused + + if q.onChange != nil { + if len(q.songs) > 0 { + q.onChange(q.songs[0], q.isPaused) + } + } +} + +func (p *Queue) setupSpeaker(s beep.Streamer, format beep.Format) (beep.Streamer, error) { + if !p.speakerInitialized { + err := speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10)) + if err != nil { + return nil, fmt.Errorf("speaker.Init: %v", err) + } + p.speakerInitialized = true + p.oldSampleRate = format.SampleRate + + return s, nil + } else { + return beep.Resample(4, format.SampleRate, p.oldSampleRate, s), nil + } +} diff --git a/src/app.go b/src/app.go index 8eed517..029644c 100644 --- a/src/app.go +++ b/src/app.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "git.sixfoisneuf.fr/termsonic/music" "github.com/delucks/go-subsonic" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -11,23 +12,26 @@ import ( type app struct { // General GUI - tv *tview.Application - pages *tview.Pages - header *tview.TextView - footer *tview.TextView - cfg *Config + tv *tview.Application + pages *tview.Pages + headerSections *tview.TextView + headerNowPlaying *tview.TextView + footer *tview.TextView + cfg *Config // Artists panel artistsTree *tview.TreeView songsList *tview.List // Subsonic variables - sub *subsonic.Client + sub *subsonic.Client + playQueue *music.Queue } func Run(cfg *Config) { a := &app{ - cfg: cfg, + cfg: cfg, + playQueue: music.NewQueue(nil), } a.tv = tview.NewApplication() @@ -35,28 +39,13 @@ func Run(cfg *Config) { a.footer = tview.NewTextView(). SetDynamicColors(true) - a.header = tview.NewTextView(). - SetRegions(true). - SetChangedFunc(func() { - a.tv.Draw() - }). - SetHighlightedFunc(func(added, _, _ []string) { - hl := added[0] - cur, _ := a.pages.GetFrontPage() - - if hl != cur { - a.switchToPage(hl) - } - }) - fmt.Fprintf(a.header, `["artists"]F1: Artists[""] | ["playlists"]F2: Playlists[""] | ["config"]F3: Configuration[""]`) - a.pages.SetBorder(true) a.pages.AddPage("config", a.configPage(), true, false) a.pages.AddPage("artists", a.artistsPage(), true, false) mainLayout := tview.NewFlex(). SetDirection(tview.FlexRow). - AddItem(a.header, 1, 1, false). + AddItem(a.buildHeader(), 1, 1, false). AddItem(a.pages, 0, 3, true). AddItem(a.footer, 1, 1, false) @@ -64,6 +53,7 @@ func Run(cfg *Config) { a.switchToPage("config") } else { a.sub, _ = buildSubsonicClient(a.cfg) + a.playQueue.SetClient(a.sub) err := a.refreshArtists() if err != nil { a.alert("Could not refresh artists: %v", err) @@ -104,14 +94,17 @@ func (a *app) switchToPage(name string) { switch name { case "artists": a.pages.SwitchToPage("artists") - a.header.Highlight("artists") + a.headerSections.Highlight("artists") a.tv.SetFocus(a.artistsTree) + a.pages.SetBorder(false) case "playlists": a.pages.SwitchToPage("playlists") - a.header.Highlight("playlists") + a.headerSections.Highlight("playlists") + a.pages.SetBorder(true) case "config": a.pages.SwitchToPage("config") - a.header.Highlight("config") + a.headerSections.Highlight("config") + a.pages.SetBorder(true) } a.updateFooter() diff --git a/src/footer.go b/src/footer.go index b0494d0..2695a61 100644 --- a/src/footer.go +++ b/src/footer.go @@ -1,14 +1,9 @@ package src func (a *app) updateFooter() { - switch a.header.GetHighlights()[0] { + switch a.headerSections.GetHighlights()[0] { case "artists": - switch a.tv.GetFocus() { - case a.artistsTree: - a.footer.SetText("Artists: [blue]Up/Down:[yellow] Move selection [blue]Space:[yellow] Select entry") - case a.songsList: - a.footer.SetText("Songs: [blue]Up/Down:[yellow] Move selection [blue]Space:[yellow] Play") - } + a.footer.SetText("[blue]l:[yellow] Next song [blue]k:[yellow] Toggle pause") case "playlists": a.footer.SetText("Come back later!") case "config": diff --git a/src/header.go b/src/header.go new file mode 100644 index 0000000..5c85808 --- /dev/null +++ b/src/header.go @@ -0,0 +1,47 @@ +package src + +import ( + "fmt" + + "github.com/delucks/go-subsonic" + "github.com/rivo/tview" +) + +func (a *app) buildHeader() tview.Primitive { + flex := tview.NewFlex() + flex.SetDirection(tview.FlexColumn) + + a.headerSections = tview.NewTextView(). + SetRegions(true). + SetChangedFunc(func() { + a.tv.Draw() + }). + SetHighlightedFunc(func(added, _, _ []string) { + hl := added[0] + cur, _ := a.pages.GetFrontPage() + + if hl != cur { + a.switchToPage(hl) + } + }) + fmt.Fprintf(a.headerSections, `["artists"]F1: Artists[""] | ["playlists"]F2: Playlists[""] | ["config"]F3: Configuration[""]`) + + a.headerNowPlaying = tview.NewTextView().SetTextAlign(tview.AlignRight) + + flex.AddItem(a.headerSections, 0, 1, false) + flex.AddItem(a.headerNowPlaying, 0, 1, false) + + a.playQueue.SetOnChangeCallback(func(song *subsonic.Child, isPaused bool) { + if song != nil { + symbol := ">" + if isPaused { + symbol = "||" + } + a.headerNowPlaying.SetText(fmt.Sprintf("%s %s - %s", symbol, song.Title, song.Artist)) + } else { + a.headerNowPlaying.SetText("Not playing") + } + }) + + return flex +} diff --git a/src/page_artists.go b/src/page_artists.go index e8ce180..4361767 100644 --- a/src/page_artists.go +++ b/src/page_artists.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + "github.com/delucks/go-subsonic" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) @@ -14,9 +15,7 @@ type selection struct { } func (a *app) artistsPage() tview.Primitive { - grid := tview.NewGrid(). - SetColumns(40, 0). - SetBorders(true) + grid := tview.NewFlex().SetDirection(tview.FlexColumn) // Artist & album list root := tview.NewTreeNode("Subsonic server").SetColor(tcell.ColorYellow) @@ -37,18 +36,18 @@ func (a *app) artistsPage() tview.Primitive { a.loadAlbumInPanel(sel.id) a.tv.SetFocus(a.songsList) - a.updateFooter() }) + a.artistsTree.SetBorderAttributes(tcell.AttrDim).SetBorder(true) // Songs list for the selected album a.songsList = tview.NewList() a.songsList.ShowSecondaryText(false) + a.songsList.SetBorderAttributes(tcell.AttrDim).SetBorder(true) // Change the left-right keys to switch between the panels a.artistsTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyLeft || event.Key() == tcell.KeyRight { a.tv.SetFocus(a.songsList) - a.updateFooter() return nil } return event @@ -57,14 +56,26 @@ func (a *app) artistsPage() tview.Primitive { a.songsList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyLeft || event.Key() == tcell.KeyRight { a.tv.SetFocus(a.artistsTree) - a.updateFooter() return nil } return event }) - grid.AddItem(a.artistsTree, 0, 0, 1, 1, 0, 0, true) - grid.AddItem(a.songsList, 0, 1, 1, 2, 0, 0, false) + grid.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Rune() == 'l' { + a.playQueue.Next() + return nil + } + + if event.Rune() == 'k' { + a.playQueue.TogglePause() + return nil + } + return event + }) + + grid.AddItem(a.artistsTree, 0, 1, true) + grid.AddItem(a.songsList, 0, 1, false) return grid } @@ -113,12 +124,32 @@ func (a *app) loadAlbumInPanel(id string) error { return err } - a.songsList.SetTitle(album.Name) + var songs []*subsonic.Child + a.songsList.Clear() - for _, song := range album.Child { + for i := len(album.Child) - 1; i >= 0; i-- { + song := album.Child[i] + songNoPtr := *song + songs = append([]*subsonic.Child{&songNoPtr}, songs...) + + songsCopy := make([]*subsonic.Child, len(songs)) + copy(songsCopy, songs) + dur := time.Duration(song.Duration) * time.Second - a.songsList.AddItem(fmt.Sprintf("%-10s %d - %s", fmt.Sprintf("[%s]", dur.String()), song.Track, song.Title), "", 0, nil) + + a.songsList.InsertItem(0, fmt.Sprintf("%-10s %d - %s", fmt.Sprintf("[%s]", dur.String()), song.Track, song.Title), "", 0, func() { + a.playQueue.Clear() + for _, s := range songsCopy { + a.playQueue.Append(s) + } + err := a.playQueue.Play() + if err != nil { + a.alert("Error: %v", err) + } + }) } + a.songsList.SetCurrentItem(0) + return nil } -- cgit v1.2.3