Web-based Winamp controller for CarPC � Go backend, mobile-first UI
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

424 linhas
12KB

  1. //go:build windows
  2. // Package server wires together the HTTP API, WebSocket hub, Winamp controller,
  3. // WASAPI viz capture, killist, and resume.
  4. package server
  5. import (
  6. "context"
  7. "encoding/json"
  8. "fmt"
  9. "log"
  10. "net/http"
  11. "os"
  12. "os/exec"
  13. "strconv"
  14. "time"
  15. "git.svabi.ch/jan/roadamp/internal/killist"
  16. "git.svabi.ch/jan/roadamp/internal/resume"
  17. "git.svabi.ch/jan/roadamp/internal/volume"
  18. "git.svabi.ch/jan/roadamp/internal/viz"
  19. "git.svabi.ch/jan/roadamp/internal/winamp"
  20. "github.com/gorilla/websocket"
  21. "gopkg.in/yaml.v3"
  22. )
  23. // ── Config ────────────────────────────────────────────────────────────────────
  24. type Config struct {
  25. Port int `yaml:"port"`
  26. WinampPath string `yaml:"winamp_path"`
  27. KillListFile string `yaml:"killist_file"`
  28. ResumeFile string `yaml:"resume_file"`
  29. }
  30. func loadConfig(path string) (Config, error) {
  31. cfg := Config{
  32. Port: 8080,
  33. WinampPath: `C:\Program Files\Winamp\Winamp.exe`,
  34. KillListFile: "killist.dat",
  35. ResumeFile: "resume.dat",
  36. }
  37. data, err := os.ReadFile(path)
  38. if os.IsNotExist(err) {
  39. return cfg, nil
  40. }
  41. if err != nil {
  42. return cfg, err
  43. }
  44. return cfg, yaml.Unmarshal(data, &cfg)
  45. }
  46. // ── Server ────────────────────────────────────────────────────────────────────
  47. type Server struct {
  48. cfg Config
  49. wa *winamp.Controller
  50. kl *killist.KillList
  51. hub *hub
  52. mux *http.ServeMux
  53. }
  54. func New(configPath string) (*Server, error) {
  55. cfg, err := loadConfig(configPath)
  56. if err != nil {
  57. return nil, fmt.Errorf("config: %w", err)
  58. }
  59. kl, err := killist.Load(cfg.KillListFile)
  60. if err != nil {
  61. return nil, fmt.Errorf("killist: %w", err)
  62. }
  63. s := &Server{cfg: cfg, wa: winamp.New(), kl: kl, mux: http.NewServeMux()}
  64. s.hub = newHub(s.handleCommand)
  65. s.routes()
  66. return s, nil
  67. }
  68. func (s *Server) Run() error {
  69. go s.hub.run()
  70. go s.broadcastStatus()
  71. go s.broadcastViz()
  72. go s.restoreResume()
  73. go s.killChecker()
  74. addr := fmt.Sprintf(":%d", s.cfg.Port)
  75. log.Printf("roadamp listening on http://localhost%s", addr)
  76. return http.ListenAndServe(addr, s.mux)
  77. }
  78. // ── Routes ────────────────────────────────────────────────────────────────────
  79. func (s *Server) routes() {
  80. s.mux.Handle("/", http.FileServer(http.Dir("web/static")))
  81. // WebSocket (primary interface)
  82. s.mux.HandleFunc("/ws", s.handleWS)
  83. // REST API (kept for curl/debugging)
  84. s.mux.HandleFunc("/api/status", s.handleStatus)
  85. s.mux.HandleFunc("/api/play", s.handlePlay)
  86. s.mux.HandleFunc("/api/pause", s.handlePause)
  87. s.mux.HandleFunc("/api/stop", s.handleStop)
  88. s.mux.HandleFunc("/api/next", s.handleNext)
  89. s.mux.HandleFunc("/api/prev", s.handlePrev)
  90. s.mux.HandleFunc("/api/seek", s.handleSeek)
  91. s.mux.HandleFunc("/api/volume", s.handleVolume)
  92. s.mux.HandleFunc("/api/mute", s.handleMute)
  93. s.mux.HandleFunc("/api/killist", s.handleKillist)
  94. s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart)
  95. }
  96. // ── WebSocket ─────────────────────────────────────────────────────────────────
  97. var wsUpgrader = websocket.Upgrader{
  98. ReadBufferSize: 1024,
  99. WriteBufferSize: 4096,
  100. CheckOrigin: func(r *http.Request) bool { return true },
  101. }
  102. func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
  103. conn, err := wsUpgrader.Upgrade(w, r, nil)
  104. if err != nil {
  105. log.Printf("ws upgrade: %v", err)
  106. return
  107. }
  108. c := &wsClient{hub: s.hub, conn: conn, send: make(chan []byte, 32)}
  109. s.hub.register <- c
  110. // Send current status immediately upon connect.
  111. if msg, err := s.statusMsg(); err == nil {
  112. c.send <- msg
  113. }
  114. go c.writePump()
  115. go c.readPump()
  116. }
  117. // ── WebSocket broadcast workers ───────────────────────────────────────────────
  118. // broadcastStatus pushes a status message to all clients every 500 ms.
  119. func (s *Server) broadcastStatus() {
  120. for range time.Tick(500 * time.Millisecond) {
  121. msg, err := s.statusMsg()
  122. if err != nil {
  123. continue
  124. }
  125. select {
  126. case s.hub.broadcast <- msg:
  127. default:
  128. }
  129. }
  130. }
  131. // broadcastViz starts the WASAPI loopback capturer and forwards spectrum
  132. // frames to all WebSocket clients at ~30 fps.
  133. func (s *Server) broadcastViz() {
  134. cap := viz.NewCapturer()
  135. ctx, cancel := context.WithCancel(context.Background())
  136. defer cancel()
  137. go cap.Start(ctx)
  138. for bars := range cap.C {
  139. data, err := json.Marshal(struct {
  140. Type string `json:"type"`
  141. Bars []float32 `json:"bars"`
  142. }{"viz", bars})
  143. if err != nil {
  144. continue
  145. }
  146. select {
  147. case s.hub.broadcast <- data:
  148. default:
  149. }
  150. }
  151. }
  152. // ── Command dispatcher (WebSocket) ────────────────────────────────────────────
  153. type wsCommand struct {
  154. Cmd string `json:"cmd"`
  155. Delta int `json:"delta"`
  156. Level int `json:"level"`
  157. Muted bool `json:"muted"`
  158. Title string `json:"title"`
  159. }
  160. func (s *Server) handleCommand(raw []byte) {
  161. var cmd wsCommand
  162. if err := json.Unmarshal(raw, &cmd); err != nil {
  163. return
  164. }
  165. switch cmd.Cmd {
  166. case "play":
  167. s.wa.Play()
  168. case "pause":
  169. s.wa.Pause()
  170. case "stop":
  171. if s.wa.IsPaused() {
  172. _ = resume.Save(s.cfg.ResumeFile, resume.State{
  173. PlaylistLength: s.wa.GetPlaylistLength(),
  174. PlaylistPos: s.wa.GetPlaylistPosition(),
  175. OffsetSeconds: s.wa.GetPosition(),
  176. TrackTitle: s.wa.GetTitle(),
  177. })
  178. }
  179. s.wa.Stop()
  180. case "next":
  181. s.wa.NextTrack()
  182. case "prev":
  183. s.wa.PrevTrack()
  184. case "seek":
  185. pos := s.wa.GetPosition() + cmd.Delta
  186. if pos < 0 {
  187. pos = 0
  188. }
  189. s.wa.Seek(pos)
  190. case "volume":
  191. _ = volume.Set(cmd.Level)
  192. case "mute":
  193. _ = volume.SetMute(cmd.Muted)
  194. case "killist_add":
  195. if title := s.wa.GetTitle(); title != "" {
  196. _ = s.kl.Add(title)
  197. }
  198. case "killist_remove":
  199. _ = s.kl.Remove(cmd.Title)
  200. case "winamp_start":
  201. if !s.wa.IsRunning() {
  202. _ = exec.Command(s.cfg.WinampPath).Start()
  203. }
  204. }
  205. // Push a fresh status after any command.
  206. if msg, err := s.statusMsg(); err == nil {
  207. select {
  208. case s.hub.broadcast <- msg:
  209. default:
  210. }
  211. }
  212. }
  213. // ── Shared status builder ─────────────────────────────────────────────────────
  214. type statusMsg struct {
  215. Type string `json:"type"`
  216. Running bool `json:"running"`
  217. State string `json:"state"`
  218. Title string `json:"title"`
  219. Position int `json:"position"`
  220. Length int `json:"length"`
  221. PlaylistPos int `json:"playlist_pos"`
  222. PlaylistLength int `json:"playlist_length"`
  223. Version string `json:"version"`
  224. Volume int `json:"volume"`
  225. Muted bool `json:"muted"`
  226. }
  227. func (s *Server) statusMsg() ([]byte, error) {
  228. msg := statusMsg{Type: "status", Running: s.wa.IsRunning()}
  229. if msg.Running {
  230. switch s.wa.PlayState() {
  231. case 1:
  232. msg.State = "playing"
  233. case 3:
  234. msg.State = "paused"
  235. default:
  236. msg.State = "stopped"
  237. }
  238. msg.Title = s.wa.GetTitle()
  239. msg.Position = s.wa.GetPosition()
  240. msg.Length = s.wa.GetLength()
  241. msg.PlaylistPos = s.wa.GetPlaylistPosition()
  242. msg.PlaylistLength = s.wa.GetPlaylistLength()
  243. msg.Version = s.wa.GetVersion()
  244. }
  245. if vol, err := volume.Get(); err == nil {
  246. msg.Volume = vol
  247. }
  248. if muted, err := volume.GetMute(); err == nil {
  249. msg.Muted = muted
  250. }
  251. return json.Marshal(msg)
  252. }
  253. // ── Background workers ────────────────────────────────────────────────────────
  254. func (s *Server) killChecker() {
  255. for range time.Tick(2 * time.Second) {
  256. if !s.wa.IsPlaying() {
  257. continue
  258. }
  259. if title := s.wa.GetTitle(); s.kl.Contains(title) {
  260. log.Printf("killist: skipping %q", title)
  261. s.wa.NextTrack()
  262. }
  263. }
  264. }
  265. func (s *Server) restoreResume() {
  266. deadline := time.Now().Add(30 * time.Second)
  267. for time.Now().Before(deadline) {
  268. if s.wa.IsRunning() {
  269. break
  270. }
  271. time.Sleep(500 * time.Millisecond)
  272. }
  273. st, err := resume.Load(s.cfg.ResumeFile)
  274. if err != nil || st == nil {
  275. return
  276. }
  277. if s.wa.GetPlaylistLength() != st.PlaylistLength {
  278. _ = resume.Delete(s.cfg.ResumeFile)
  279. return
  280. }
  281. s.wa.Play()
  282. s.wa.Seek(st.OffsetSeconds)
  283. s.wa.Pause()
  284. _ = resume.Delete(s.cfg.ResumeFile)
  285. log.Printf("resume: restored %q at %ds", st.TrackTitle, st.OffsetSeconds)
  286. }
  287. // ── REST handlers (for curl/debug) ───────────────────────────────────────────
  288. func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
  289. msg, err := s.statusMsg()
  290. if err != nil {
  291. http.Error(w, err.Error(), 500)
  292. return
  293. }
  294. w.Header().Set("Content-Type", "application/json")
  295. w.Write(msg)
  296. }
  297. func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.Play()) }
  298. func (s *Server) handlePause(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.Pause()) }
  299. func (s *Server) handleNext(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.NextTrack()) }
  300. func (s *Server) handlePrev(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.PrevTrack()) }
  301. func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
  302. if s.wa.IsPaused() {
  303. _ = resume.Save(s.cfg.ResumeFile, resume.State{
  304. PlaylistLength: s.wa.GetPlaylistLength(),
  305. PlaylistPos: s.wa.GetPlaylistPosition(),
  306. OffsetSeconds: s.wa.GetPosition(),
  307. TrackTitle: s.wa.GetTitle(),
  308. })
  309. }
  310. jsonOK(w, s.wa.Stop())
  311. }
  312. func (s *Server) handleSeek(w http.ResponseWriter, r *http.Request) {
  313. delta, err := strconv.Atoi(r.URL.Query().Get("delta"))
  314. if err != nil {
  315. http.Error(w, "invalid delta", http.StatusBadRequest)
  316. return
  317. }
  318. pos := s.wa.GetPosition() + delta
  319. if pos < 0 {
  320. pos = 0
  321. }
  322. jsonOK(w, s.wa.Seek(pos))
  323. }
  324. func (s *Server) handleVolume(w http.ResponseWriter, r *http.Request) {
  325. if r.Method == http.MethodGet {
  326. vol, _ := volume.Get()
  327. jsonOK(w, map[string]int{"volume": vol})
  328. return
  329. }
  330. lvl, err := strconv.Atoi(r.URL.Query().Get("level"))
  331. if err != nil {
  332. http.Error(w, "invalid level", http.StatusBadRequest)
  333. return
  334. }
  335. _ = volume.Set(lvl)
  336. jsonOK(w, map[string]int{"volume": lvl})
  337. }
  338. func (s *Server) handleMute(w http.ResponseWriter, r *http.Request) {
  339. if r.Method == http.MethodGet {
  340. muted, _ := volume.GetMute()
  341. jsonOK(w, map[string]bool{"muted": muted})
  342. return
  343. }
  344. val := r.URL.Query().Get("muted")
  345. muted := val == "true" || val == "1"
  346. _ = volume.SetMute(muted)
  347. jsonOK(w, map[string]bool{"muted": muted})
  348. }
  349. func (s *Server) handleKillist(w http.ResponseWriter, r *http.Request) {
  350. switch r.Method {
  351. case http.MethodGet:
  352. jsonOK(w, s.kl.List())
  353. case http.MethodPost:
  354. title := s.wa.GetTitle()
  355. if title == "" {
  356. http.Error(w, "no track playing", http.StatusConflict)
  357. return
  358. }
  359. _ = s.kl.Add(title)
  360. jsonOK(w, map[string]string{"added": title})
  361. case http.MethodDelete:
  362. _ = s.kl.Remove(r.URL.Query().Get("title"))
  363. jsonOK(w, map[string]bool{"ok": true})
  364. }
  365. }
  366. func (s *Server) handleWinampStart(w http.ResponseWriter, r *http.Request) {
  367. if s.wa.IsRunning() {
  368. jsonOK(w, map[string]string{"status": "already_running"})
  369. return
  370. }
  371. if err := exec.Command(s.cfg.WinampPath).Start(); err != nil {
  372. http.Error(w, err.Error(), 500)
  373. return
  374. }
  375. jsonOK(w, map[string]string{"status": "started"})
  376. }
  377. func jsonOK(w http.ResponseWriter, v any) {
  378. w.Header().Set("Content-Type", "application/json")
  379. json.NewEncoder(w).Encode(v)
  380. }