Web-based Winamp controller for CarPC � Go backend, mobile-first UI
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

429 lignes
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. // Winamp returns 0xFFFFFFFF when stopped/no track — clamp to 0.
  240. pos := s.wa.GetPosition()
  241. length := s.wa.GetLength()
  242. if pos > 86400 { pos = 0 } // > 24h → garbage value
  243. if length > 86400 { length = 0 }
  244. msg.Position = pos
  245. msg.Length = length
  246. msg.PlaylistPos = s.wa.GetPlaylistPosition()
  247. msg.PlaylistLength = s.wa.GetPlaylistLength()
  248. msg.Version = s.wa.GetVersion()
  249. }
  250. if vol, err := volume.Get(); err == nil {
  251. msg.Volume = vol
  252. }
  253. if muted, err := volume.GetMute(); err == nil {
  254. msg.Muted = muted
  255. }
  256. return json.Marshal(msg)
  257. }
  258. // ── Background workers ────────────────────────────────────────────────────────
  259. func (s *Server) killChecker() {
  260. for range time.Tick(2 * time.Second) {
  261. if !s.wa.IsPlaying() {
  262. continue
  263. }
  264. if title := s.wa.GetTitle(); s.kl.Contains(title) {
  265. log.Printf("killist: skipping %q", title)
  266. s.wa.NextTrack()
  267. }
  268. }
  269. }
  270. func (s *Server) restoreResume() {
  271. deadline := time.Now().Add(30 * time.Second)
  272. for time.Now().Before(deadline) {
  273. if s.wa.IsRunning() {
  274. break
  275. }
  276. time.Sleep(500 * time.Millisecond)
  277. }
  278. st, err := resume.Load(s.cfg.ResumeFile)
  279. if err != nil || st == nil {
  280. return
  281. }
  282. if s.wa.GetPlaylistLength() != st.PlaylistLength {
  283. _ = resume.Delete(s.cfg.ResumeFile)
  284. return
  285. }
  286. s.wa.Play()
  287. s.wa.Seek(st.OffsetSeconds)
  288. s.wa.Pause()
  289. _ = resume.Delete(s.cfg.ResumeFile)
  290. log.Printf("resume: restored %q at %ds", st.TrackTitle, st.OffsetSeconds)
  291. }
  292. // ── REST handlers (for curl/debug) ───────────────────────────────────────────
  293. func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
  294. msg, err := s.statusMsg()
  295. if err != nil {
  296. http.Error(w, err.Error(), 500)
  297. return
  298. }
  299. w.Header().Set("Content-Type", "application/json")
  300. w.Write(msg)
  301. }
  302. func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.Play()) }
  303. func (s *Server) handlePause(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.Pause()) }
  304. func (s *Server) handleNext(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.NextTrack()) }
  305. func (s *Server) handlePrev(w http.ResponseWriter, r *http.Request) { jsonOK(w, s.wa.PrevTrack()) }
  306. func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
  307. if s.wa.IsPaused() {
  308. _ = resume.Save(s.cfg.ResumeFile, resume.State{
  309. PlaylistLength: s.wa.GetPlaylistLength(),
  310. PlaylistPos: s.wa.GetPlaylistPosition(),
  311. OffsetSeconds: s.wa.GetPosition(),
  312. TrackTitle: s.wa.GetTitle(),
  313. })
  314. }
  315. jsonOK(w, s.wa.Stop())
  316. }
  317. func (s *Server) handleSeek(w http.ResponseWriter, r *http.Request) {
  318. delta, err := strconv.Atoi(r.URL.Query().Get("delta"))
  319. if err != nil {
  320. http.Error(w, "invalid delta", http.StatusBadRequest)
  321. return
  322. }
  323. pos := s.wa.GetPosition() + delta
  324. if pos < 0 {
  325. pos = 0
  326. }
  327. jsonOK(w, s.wa.Seek(pos))
  328. }
  329. func (s *Server) handleVolume(w http.ResponseWriter, r *http.Request) {
  330. if r.Method == http.MethodGet {
  331. vol, _ := volume.Get()
  332. jsonOK(w, map[string]int{"volume": vol})
  333. return
  334. }
  335. lvl, err := strconv.Atoi(r.URL.Query().Get("level"))
  336. if err != nil {
  337. http.Error(w, "invalid level", http.StatusBadRequest)
  338. return
  339. }
  340. _ = volume.Set(lvl)
  341. jsonOK(w, map[string]int{"volume": lvl})
  342. }
  343. func (s *Server) handleMute(w http.ResponseWriter, r *http.Request) {
  344. if r.Method == http.MethodGet {
  345. muted, _ := volume.GetMute()
  346. jsonOK(w, map[string]bool{"muted": muted})
  347. return
  348. }
  349. val := r.URL.Query().Get("muted")
  350. muted := val == "true" || val == "1"
  351. _ = volume.SetMute(muted)
  352. jsonOK(w, map[string]bool{"muted": muted})
  353. }
  354. func (s *Server) handleKillist(w http.ResponseWriter, r *http.Request) {
  355. switch r.Method {
  356. case http.MethodGet:
  357. jsonOK(w, s.kl.List())
  358. case http.MethodPost:
  359. title := s.wa.GetTitle()
  360. if title == "" {
  361. http.Error(w, "no track playing", http.StatusConflict)
  362. return
  363. }
  364. _ = s.kl.Add(title)
  365. jsonOK(w, map[string]string{"added": title})
  366. case http.MethodDelete:
  367. _ = s.kl.Remove(r.URL.Query().Get("title"))
  368. jsonOK(w, map[string]bool{"ok": true})
  369. }
  370. }
  371. func (s *Server) handleWinampStart(w http.ResponseWriter, r *http.Request) {
  372. if s.wa.IsRunning() {
  373. jsonOK(w, map[string]string{"status": "already_running"})
  374. return
  375. }
  376. if err := exec.Command(s.cfg.WinampPath).Start(); err != nil {
  377. http.Error(w, err.Error(), 500)
  378. return
  379. }
  380. jsonOK(w, map[string]string{"status": "started"})
  381. }
  382. func jsonOK(w http.ResponseWriter, v any) {
  383. w.Header().Set("Content-Type", "application/json")
  384. json.NewEncoder(w).Encode(v)
  385. }