- internal/volume: IAudioEndpointVolume via COM (pure Go, no CGO) - Get/Set master volume (0-100%) - GetMute/SetMute - Uses WASAPI instead of deprecated MMSystem mixer API - server: /api/volume now uses system volume (0-100, was Winamp 0-255) - server: new /api/mute endpoint (GET + POST ?muted=true|false) - server: volume+mute included in /api/status response - frontend: mute button with visual state (🔊/🔇, red bar when muted) - frontend: volume display as percentage label Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>master
| @@ -2,4 +2,7 @@ module git.svabi.ch/jan/roadamp | |||||
| go 1.25.0 | go 1.25.0 | ||||
| require gopkg.in/yaml.v3 v3.0.1 // indirect | |||||
| require ( | |||||
| golang.org/x/sys v0.45.0 // indirect | |||||
| gopkg.in/yaml.v3 v3.0.1 // indirect | |||||
| ) | |||||
| @@ -1,3 +1,5 @@ | |||||
| golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= | |||||
| golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | |||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
| @@ -14,6 +14,7 @@ import ( | |||||
| "git.svabi.ch/jan/roadamp/internal/killist" | "git.svabi.ch/jan/roadamp/internal/killist" | ||||
| "git.svabi.ch/jan/roadamp/internal/resume" | "git.svabi.ch/jan/roadamp/internal/resume" | ||||
| "git.svabi.ch/jan/roadamp/internal/volume" | |||||
| "git.svabi.ch/jan/roadamp/internal/winamp" | "git.svabi.ch/jan/roadamp/internal/winamp" | ||||
| "gopkg.in/yaml.v3" | "gopkg.in/yaml.v3" | ||||
| ) | ) | ||||
| @@ -96,7 +97,8 @@ func (s *Server) routes() { | |||||
| s.mux.HandleFunc("/api/next", s.handleNext) | s.mux.HandleFunc("/api/next", s.handleNext) | ||||
| s.mux.HandleFunc("/api/prev", s.handlePrev) | s.mux.HandleFunc("/api/prev", s.handlePrev) | ||||
| s.mux.HandleFunc("/api/seek", s.handleSeek) // ?delta=±N (seconds) | s.mux.HandleFunc("/api/seek", s.handleSeek) // ?delta=±N (seconds) | ||||
| s.mux.HandleFunc("/api/volume", s.handleVolume) // ?level=0-255 | |||||
| s.mux.HandleFunc("/api/volume", s.handleVolume) // GET | POST ?level=0-100 | |||||
| s.mux.HandleFunc("/api/mute", s.handleMute) // GET | POST ?muted=true|false | |||||
| s.mux.HandleFunc("/api/killist", s.handleKillist) | s.mux.HandleFunc("/api/killist", s.handleKillist) | ||||
| s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart) | s.mux.HandleFunc("/api/winamp/start", s.handleWinampStart) | ||||
| } | } | ||||
| @@ -112,6 +114,8 @@ type statusResponse struct { | |||||
| PlaylistPos int `json:"playlist_pos"` | PlaylistPos int `json:"playlist_pos"` | ||||
| PlaylistLength int `json:"playlist_length"` | PlaylistLength int `json:"playlist_length"` | ||||
| Version string `json:"version"` | Version string `json:"version"` | ||||
| Volume int `json:"volume"` // system master volume 0–100 | |||||
| Muted bool `json:"muted"` | |||||
| } | } | ||||
| func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { | func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { | ||||
| @@ -132,6 +136,13 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { | |||||
| resp.PlaylistLength = s.wa.GetPlaylistLength() | resp.PlaylistLength = s.wa.GetPlaylistLength() | ||||
| resp.Version = s.wa.GetVersion() | resp.Version = s.wa.GetVersion() | ||||
| } | } | ||||
| // System volume is available regardless of whether Winamp is running. | |||||
| if vol, err := volume.Get(); err == nil { | |||||
| resp.Volume = vol | |||||
| } | |||||
| if muted, err := volume.GetMute(); err == nil { | |||||
| resp.Muted = muted | |||||
| } | |||||
| jsonOK(w, resp) | jsonOK(w, resp) | ||||
| } | } | ||||
| @@ -184,13 +195,44 @@ func (s *Server) handleSeek(w http.ResponseWriter, r *http.Request) { | |||||
| } | } | ||||
| func (s *Server) handleVolume(w http.ResponseWriter, r *http.Request) { | func (s *Server) handleVolume(w http.ResponseWriter, r *http.Request) { | ||||
| if r.Method == http.MethodGet { | |||||
| vol, err := volume.Get() | |||||
| if err != nil { | |||||
| http.Error(w, err.Error(), http.StatusInternalServerError) | |||||
| return | |||||
| } | |||||
| jsonOK(w, map[string]int{"volume": vol}) | |||||
| return | |||||
| } | |||||
| lvl, err := strconv.Atoi(r.URL.Query().Get("level")) | lvl, err := strconv.Atoi(r.URL.Query().Get("level")) | ||||
| if err != nil { | if err != nil { | ||||
| http.Error(w, "invalid level", http.StatusBadRequest) | |||||
| http.Error(w, "invalid level (0–100)", http.StatusBadRequest) | |||||
| return | return | ||||
| } | } | ||||
| ok := s.wa.SetVolume(lvl) | |||||
| jsonOK(w, map[string]bool{"ok": ok}) | |||||
| if err := volume.Set(lvl); err != nil { | |||||
| http.Error(w, err.Error(), http.StatusInternalServerError) | |||||
| return | |||||
| } | |||||
| jsonOK(w, map[string]int{"volume": lvl}) | |||||
| } | |||||
| func (s *Server) handleMute(w http.ResponseWriter, r *http.Request) { | |||||
| if r.Method == http.MethodGet { | |||||
| muted, err := volume.GetMute() | |||||
| if err != nil { | |||||
| http.Error(w, err.Error(), http.StatusInternalServerError) | |||||
| return | |||||
| } | |||||
| jsonOK(w, map[string]bool{"muted": muted}) | |||||
| return | |||||
| } | |||||
| val := r.URL.Query().Get("muted") | |||||
| muted := val == "true" || val == "1" | |||||
| if err := volume.SetMute(muted); err != nil { | |||||
| http.Error(w, err.Error(), http.StatusInternalServerError) | |||||
| return | |||||
| } | |||||
| jsonOK(w, map[string]bool{"muted": muted}) | |||||
| } | } | ||||
| func (s *Server) handleKillist(w http.ResponseWriter, r *http.Request) { | func (s *Server) handleKillist(w http.ResponseWriter, r *http.Request) { | ||||
| @@ -0,0 +1,239 @@ | |||||
| //go:build windows | |||||
| // Package volume controls the Windows system master volume via the | |||||
| // Core Audio API (IAudioEndpointVolume COM interface). | |||||
| // This is the modern replacement for the deprecated MMSystem mixer API. | |||||
| package volume | |||||
| import ( | |||||
| "fmt" | |||||
| "syscall" | |||||
| "unsafe" | |||||
| "golang.org/x/sys/windows" | |||||
| ) | |||||
| // ── COM GUIDs ───────────────────────────────────────────────────────────────── | |||||
| var ( | |||||
| clsidMMDeviceEnumerator = windows.GUID{ | |||||
| Data1: 0xBCDE0395, Data2: 0xE52F, Data3: 0x467C, | |||||
| Data4: [8]byte{0x8E, 0x3D, 0xC4, 0x57, 0x92, 0x91, 0x69, 0x2E}, | |||||
| } | |||||
| iidIMMDeviceEnumerator = windows.GUID{ | |||||
| Data1: 0xA95664D2, Data2: 0x9614, Data3: 0x4F35, | |||||
| Data4: [8]byte{0xA7, 0x46, 0xDE, 0x8D, 0xB6, 0x36, 0x17, 0xE6}, | |||||
| } | |||||
| iidIAudioEndpointVolume = windows.GUID{ | |||||
| Data1: 0x5CDF2C82, Data2: 0x841E, Data3: 0x4546, | |||||
| Data4: [8]byte{0x97, 0x22, 0x0C, 0xF7, 0x40, 0x78, 0x22, 0x9A}, | |||||
| } | |||||
| ) | |||||
| const ( | |||||
| eRender uintptr = 0 | |||||
| eConsole uintptr = 0 | |||||
| clsctxAll uintptr = 0x17 | |||||
| ) | |||||
| // ── Helper: call a COM vtable method ───────────────────────────────────────── | |||||
| // vtbl is a pointer to the vtable struct (first field of the COM object). | |||||
| // idx is the method index (0 = QueryInterface, 1 = AddRef, 2 = Release, …). | |||||
| func comCall3(vtbl uintptr, idx int, a1, a2, a3 uintptr) (uintptr, error) { | |||||
| proc := *(*uintptr)(unsafe.Pointer(vtbl + uintptr(idx)*unsafe.Sizeof(uintptr(0)))) | |||||
| r, _, _ := syscall.Syscall6(proc, 4, a1, a2, a3, 0, 0, 0) | |||||
| if r != 0 { | |||||
| return 0, fmt.Errorf("COM call [%d]: HRESULT 0x%08X", idx, r) | |||||
| } | |||||
| return r, nil | |||||
| } | |||||
| func comCall4(vtbl uintptr, idx int, a1, a2, a3, a4 uintptr) (uintptr, error) { | |||||
| proc := *(*uintptr)(unsafe.Pointer(vtbl + uintptr(idx)*unsafe.Sizeof(uintptr(0)))) | |||||
| r, _, _ := syscall.Syscall6(proc, 5, a1, a2, a3, a4, 0, 0) | |||||
| if r != 0 { | |||||
| return 0, fmt.Errorf("COM call [%d]: HRESULT 0x%08X", idx, r) | |||||
| } | |||||
| return r, nil | |||||
| } | |||||
| func comCall5(vtbl uintptr, idx int, a1, a2, a3, a4, a5 uintptr) (uintptr, error) { | |||||
| proc := *(*uintptr)(unsafe.Pointer(vtbl + uintptr(idx)*unsafe.Sizeof(uintptr(0)))) | |||||
| r, _, _ := syscall.Syscall6(proc, 6, a1, a2, a3, a4, a5, 0) | |||||
| if r != 0 { | |||||
| return 0, fmt.Errorf("COM call [%d]: HRESULT 0x%08X", idx, r) | |||||
| } | |||||
| return r, nil | |||||
| } | |||||
| // vtblOf returns the vtable pointer for a COM object pointer. | |||||
| func vtblOf(p uintptr) uintptr { | |||||
| return *(*uintptr)(unsafe.Pointer(p)) | |||||
| } | |||||
| // release calls IUnknown::Release (vtable index 2). | |||||
| func release(p uintptr) { | |||||
| proc := *(*uintptr)(unsafe.Pointer(vtblOf(p) + 2*unsafe.Sizeof(uintptr(0)))) | |||||
| syscall.Syscall(proc, 1, p, 0, 0) | |||||
| } | |||||
| // ── DLL procedures ──────────────────────────────────────────────────────────── | |||||
| var ( | |||||
| ole32 = windows.NewLazySystemDLL("ole32.dll") | |||||
| coInitializeEx = ole32.NewProc("CoInitializeEx") | |||||
| coUninitialize = ole32.NewProc("CoUninitialize") | |||||
| coCreateInstance = ole32.NewProc("CoCreateInstance") | |||||
| ) | |||||
| // ── withEndpointVolume ──────────────────────────────────────────────────────── | |||||
| // withEndpointVolume acquires an IAudioEndpointVolume for the default render | |||||
| // device, calls fn with its pointer, and releases all COM objects. | |||||
| func withEndpointVolume(fn func(vol uintptr) error) error { | |||||
| coInitializeEx.Call(0, 0) // COINIT_MULTITHREADED | |||||
| defer coUninitialize.Call() | |||||
| // CoCreateInstance(CLSID_MMDeviceEnumerator) → IMMDeviceEnumerator | |||||
| var enumerator uintptr | |||||
| hr, _, _ := coCreateInstance.Call( | |||||
| uintptr(unsafe.Pointer(&clsidMMDeviceEnumerator)), | |||||
| 0, | |||||
| clsctxAll, | |||||
| uintptr(unsafe.Pointer(&iidIMMDeviceEnumerator)), | |||||
| uintptr(unsafe.Pointer(&enumerator)), | |||||
| ) | |||||
| if hr != 0 { | |||||
| return fmt.Errorf("CoCreateInstance: HRESULT 0x%08X", hr) | |||||
| } | |||||
| defer release(enumerator) | |||||
| // IMMDeviceEnumerator::GetDefaultAudioEndpoint (vtable index 4) | |||||
| // (0=QI, 1=AddRef, 2=Release, 3=EnumAudioEndpoints, 4=GetDefaultAudioEndpoint) | |||||
| var device uintptr | |||||
| if _, err := comCall5(vtblOf(enumerator), 4, | |||||
| enumerator, eRender, eConsole, | |||||
| uintptr(unsafe.Pointer(&device)), 0); err != nil { | |||||
| return fmt.Errorf("GetDefaultAudioEndpoint: %w", err) | |||||
| } | |||||
| defer release(device) | |||||
| // IMMDevice::Activate(IID_IAudioEndpointVolume, CLSCTX_ALL, nil, &vol) | |||||
| // (0=QI, 1=AddRef, 2=Release, 3=Activate, …) | |||||
| var vol uintptr | |||||
| if _, err := comCall5(vtblOf(device), 3, | |||||
| device, | |||||
| uintptr(unsafe.Pointer(&iidIAudioEndpointVolume)), | |||||
| clsctxAll, | |||||
| 0, | |||||
| uintptr(unsafe.Pointer(&vol))); err != nil { | |||||
| return fmt.Errorf("IMMDevice::Activate: %w", err) | |||||
| } | |||||
| defer release(vol) | |||||
| return fn(vol) | |||||
| } | |||||
| // ── IAudioEndpointVolume vtable indices ─────────────────────────────────────── | |||||
| // | |||||
| // 0 QueryInterface | |||||
| // 1 AddRef | |||||
| // 2 Release | |||||
| // 3 RegisterControlChangeNotify | |||||
| // 4 UnregisterControlChangeNotify | |||||
| // 5 GetChannelCount | |||||
| // 6 SetMasterVolumeLevel | |||||
| // 7 SetMasterVolumeLevelScalar | |||||
| // 8 GetMasterVolumeLevel | |||||
| // 9 GetMasterVolumeLevelScalar | |||||
| // 14 SetMute | |||||
| // 15 GetMute | |||||
| const ( | |||||
| idxSetMasterScalar = 7 | |||||
| idxGetMasterScalar = 9 | |||||
| idxSetMute = 14 | |||||
| idxGetMute = 15 | |||||
| ) | |||||
| // ── Public API ──────────────────────────────────────────────────────────────── | |||||
| // Get returns the current Windows master volume as a percentage (0–100). | |||||
| func Get() (int, error) { | |||||
| var pct int | |||||
| err := withEndpointVolume(func(vol uintptr) error { | |||||
| var level float32 | |||||
| proc := *(*uintptr)(unsafe.Pointer( | |||||
| vtblOf(vol) + idxGetMasterScalar*unsafe.Sizeof(uintptr(0)), | |||||
| )) | |||||
| r, _, _ := syscall.Syscall(proc, 2, vol, uintptr(unsafe.Pointer(&level)), 0) | |||||
| if r != 0 { | |||||
| return fmt.Errorf("GetMasterVolumeLevelScalar: 0x%08X", r) | |||||
| } | |||||
| pct = int(level * 100) | |||||
| return nil | |||||
| }) | |||||
| return pct, err | |||||
| } | |||||
| // Set sets the Windows master volume to pct percent (0–100). | |||||
| func Set(pct int) error { | |||||
| if pct < 0 { | |||||
| pct = 0 | |||||
| } | |||||
| if pct > 100 { | |||||
| pct = 100 | |||||
| } | |||||
| level := float32(pct) / 100.0 | |||||
| return withEndpointVolume(func(vol uintptr) error { | |||||
| var nullGUID windows.GUID | |||||
| proc := *(*uintptr)(unsafe.Pointer( | |||||
| vtblOf(vol) + idxSetMasterScalar*unsafe.Sizeof(uintptr(0)), | |||||
| )) | |||||
| // float32 must be widened to uintptr via bit-reinterpretation | |||||
| bits := *(*uint32)(unsafe.Pointer(&level)) | |||||
| r, _, _ := syscall.Syscall(proc, 3, vol, uintptr(bits), uintptr(unsafe.Pointer(&nullGUID))) | |||||
| if r != 0 { | |||||
| return fmt.Errorf("SetMasterVolumeLevelScalar: 0x%08X", r) | |||||
| } | |||||
| return nil | |||||
| }) | |||||
| } | |||||
| // GetMute reports whether the system audio is currently muted. | |||||
| func GetMute() (bool, error) { | |||||
| var muted bool | |||||
| err := withEndpointVolume(func(vol uintptr) error { | |||||
| var val int32 | |||||
| proc := *(*uintptr)(unsafe.Pointer( | |||||
| vtblOf(vol) + idxGetMute*unsafe.Sizeof(uintptr(0)), | |||||
| )) | |||||
| r, _, _ := syscall.Syscall(proc, 2, vol, uintptr(unsafe.Pointer(&val)), 0) | |||||
| if r != 0 { | |||||
| return fmt.Errorf("GetMute: 0x%08X", r) | |||||
| } | |||||
| muted = val != 0 | |||||
| return nil | |||||
| }) | |||||
| return muted, err | |||||
| } | |||||
| // SetMute mutes or unmutes the system audio. | |||||
| func SetMute(muted bool) error { | |||||
| return withEndpointVolume(func(vol uintptr) error { | |||||
| var nullGUID windows.GUID | |||||
| val := uintptr(0) | |||||
| if muted { | |||||
| val = 1 | |||||
| } | |||||
| proc := *(*uintptr)(unsafe.Pointer( | |||||
| vtblOf(vol) + idxSetMute*unsafe.Sizeof(uintptr(0)), | |||||
| )) | |||||
| r, _, _ := syscall.Syscall(proc, 3, vol, val, uintptr(unsafe.Pointer(&nullGUID))) | |||||
| if r != 0 { | |||||
| return fmt.Errorf("SetMute: 0x%08X", r) | |||||
| } | |||||
| return nil | |||||
| }) | |||||
| } | |||||
| @@ -4,20 +4,22 @@ const api = (path, opts = {}) => | |||||
| fetch(path, opts).then(r => r.json()).catch(() => null); | fetch(path, opts).then(r => r.json()).catch(() => null); | ||||
| // ── State ───────────────────────────────────────────────────────────────────── | // ── State ───────────────────────────────────────────────────────────────────── | ||||
| let currentVolume = 180; // 0–255 | |||||
| let currentVolume = 50; // 0–100 (Windows system volume) | |||||
| let pollTimer = null; | let pollTimer = null; | ||||
| // ── DOM refs ────────────────────────────────────────────────────────────────── | // ── DOM refs ────────────────────────────────────────────────────────────────── | ||||
| const $ = id => document.getElementById(id); | const $ = id => document.getElementById(id); | ||||
| const statusDot = $('winamp-status'); | |||||
| const stateLabel = $('state-label'); | |||||
| const trackTitle = $('track-title'); | |||||
| const playlistPos = $('playlist-pos'); | |||||
| const statusDot = $('winamp-status'); | |||||
| const stateLabel = $('state-label'); | |||||
| const trackTitle = $('track-title'); | |||||
| const playlistPos = $('playlist-pos'); | |||||
| const progressFill = $('progress-fill'); | const progressFill = $('progress-fill'); | ||||
| const timeCurrent = $('time-current'); | |||||
| const timeLength = $('time-length'); | |||||
| const volumeFill = $('volume-fill'); | |||||
| const btnPlay = $('btn-play'); | |||||
| const timeCurrent = $('time-current'); | |||||
| const timeLength = $('time-length'); | |||||
| const volumeFill = $('volume-fill'); | |||||
| const volumePct = $('volume-pct'); | |||||
| const btnMute = $('btn-mute'); | |||||
| const btnPlay = $('btn-play'); | |||||
| const killistPanel = $('killist-panel'); | const killistPanel = $('killist-panel'); | ||||
| const killistItems = $('killist-items'); | const killistItems = $('killist-items'); | ||||
| @@ -66,23 +68,37 @@ $('progress-bar').addEventListener('click', async e => { | |||||
| // ── Volume ──────────────────────────────────────────────────────────────────── | // ── Volume ──────────────────────────────────────────────────────────────────── | ||||
| $('btn-vol-up').addEventListener('click', async () => { | $('btn-vol-up').addEventListener('click', async () => { | ||||
| currentVolume = Math.min(255, currentVolume + 13); // ~5% | |||||
| currentVolume = Math.min(100, currentVolume + 5); | |||||
| await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); | await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); | ||||
| updateVolumeFill(); | updateVolumeFill(); | ||||
| }); | }); | ||||
| $('btn-vol-down').addEventListener('click', async () => { | $('btn-vol-down').addEventListener('click', async () => { | ||||
| currentVolume = Math.max(0, currentVolume - 13); | |||||
| currentVolume = Math.max(0, currentVolume - 5); | |||||
| await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); | await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); | ||||
| updateVolumeFill(); | updateVolumeFill(); | ||||
| }); | }); | ||||
| $('volume-bar').addEventListener('click', async e => { | $('volume-bar').addEventListener('click', async e => { | ||||
| const rect = e.currentTarget.getBoundingClientRect(); | const rect = e.currentTarget.getBoundingClientRect(); | ||||
| currentVolume = Math.round((e.clientX - rect.left) / rect.width * 255); | |||||
| currentVolume = Math.round((e.clientX - rect.left) / rect.width * 100); | |||||
| await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); | await api(`/api/volume?level=${currentVolume}`, { method: 'POST' }); | ||||
| updateVolumeFill(); | updateVolumeFill(); | ||||
| }); | }); | ||||
| function updateVolumeFill() { | |||||
| volumeFill.style.width = (currentVolume / 255 * 100) + '%'; | |||||
| btnMute.addEventListener('click', async () => { | |||||
| const cur = await api('/api/mute'); | |||||
| const newMuted = !(cur?.muted); | |||||
| await api(`/api/mute?muted=${newMuted}`, { method: 'POST' }); | |||||
| updateMuteBtn(newMuted); | |||||
| }); | |||||
| function updateVolumeFill(muted = false) { | |||||
| volumeFill.style.width = currentVolume + '%'; | |||||
| volumePct.textContent = currentVolume + ' %'; | |||||
| volumeFill.classList.toggle('muted', muted); | |||||
| } | |||||
| function updateMuteBtn(muted) { | |||||
| btnMute.textContent = muted ? '🔇' : '🔊'; | |||||
| btnMute.classList.toggle('muted', muted); | |||||
| volumeFill.classList.toggle('muted', muted); | |||||
| } | } | ||||
| // ── KillList ────────────────────────────────────────────────────────────────── | // ── KillList ────────────────────────────────────────────────────────────────── | ||||
| @@ -154,6 +170,13 @@ async function poll() { | |||||
| // Reflect play/pause state on button | // Reflect play/pause state on button | ||||
| btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶'; | btnPlay.textContent = st.state === 'playing' ? '⏸' : '▶'; | ||||
| // System volume (always present in status response) | |||||
| if (typeof st.volume === 'number') { | |||||
| currentVolume = st.volume; | |||||
| updateVolumeFill(st.muted); | |||||
| updateMuteBtn(st.muted); | |||||
| } | |||||
| } | } | ||||
| function startPolling(intervalMs = 2000) { | function startPolling(intervalMs = 2000) { | ||||
| @@ -43,13 +43,15 @@ | |||||
| </div> | </div> | ||||
| <div id="volume-row"> | <div id="volume-row"> | ||||
| <button class="btn btn-vol" id="btn-vol-down">🔉</button> | |||||
| <button class="btn btn-vol" id="btn-mute">🔊</button> | |||||
| <button class="btn btn-vol" id="btn-vol-down">−</button> | |||||
| <div id="volume-bar-wrap"> | <div id="volume-bar-wrap"> | ||||
| <div id="volume-bar"> | <div id="volume-bar"> | ||||
| <div id="volume-fill"></div> | <div id="volume-fill"></div> | ||||
| </div> | </div> | ||||
| <div id="volume-pct">–</div> | |||||
| </div> | </div> | ||||
| <button class="btn btn-vol" id="btn-vol-up">🔊</button> | |||||
| <button class="btn btn-vol" id="btn-vol-up">+</button> | |||||
| </div> | </div> | ||||
| <div id="killist-row"> | <div id="killist-row"> | ||||
| @@ -134,10 +134,12 @@ html, body { | |||||
| #volume-row { | #volume-row { | ||||
| display: flex; | display: flex; | ||||
| align-items: center; | align-items: center; | ||||
| gap: 12px; | |||||
| gap: 10px; | |||||
| } | } | ||||
| .btn-vol { width: 52px; height: 52px; flex-shrink: 0; } | |||||
| #volume-bar-wrap { flex: 1; } | |||||
| .btn-vol { width: 48px; height: 48px; flex-shrink: 0; font-size: 18px; } | |||||
| #btn-mute { font-size: 22px; } | |||||
| #btn-mute.muted { color: var(--accent); } | |||||
| #volume-bar-wrap { flex: 1; display: flex; flex-direction: column; gap: 4px; } | |||||
| #volume-bar { | #volume-bar { | ||||
| height: 8px; | height: 8px; | ||||
| background: var(--accent2); | background: var(--accent2); | ||||
| @@ -152,6 +154,12 @@ html, body { | |||||
| transition: width 0.2s; | transition: width 0.2s; | ||||
| border-radius: 4px; | border-radius: 4px; | ||||
| } | } | ||||
| #volume-pct { | |||||
| font-size: 11px; | |||||
| color: var(--text-dim); | |||||
| text-align: center; | |||||
| } | |||||
| #volume-fill.muted { background: var(--accent); } | |||||
| /* Killist */ | /* Killist */ | ||||
| #killist-row { | #killist-row { | ||||