#Requires AutoHotkey v2.0 #SingleInstance Force ; ============================================================================ ; volstep-osd.ahk — fine volume steps + play/pause remap + a compact, ; icon-based, RDP-safe on-screen readout. ; ; Knob spin -> volume up/down by the selected step (0.5/1/2/4%). ; Knob click -> Play/Pause (single) + Mute/unmute (double). [tray-toggle] ; Tray icon (right-click): step size, toggle click remap, Exit. Persists to .ini ; ; Icons (Segoe MDL2 Assets) mutate with state: ; volume 0-3 waves by level | mute = speaker-with-line | play ▶ / pause ❚❚ ; Mute + volume are ACCURATE (read from the system). Play/pause is tracked from ; clicks (right while the knob drives playback; can drift if changed elsewhere). ; ; Plain opaque window (no transparency/layered — those go invisible over RDP). ; ============================================================================ ; ---------------------------- CONFIG ---------------------------------------- PANEL_BG := "16171b" TRACK := "2b2e37" ACCENT := GetAccentColor() ; bar fill = your Windows accent (or hardcode "4cc2ff") STEP_CHOICES := [0.5, 1, 2, 4] ; step sizes offered in the tray menu (% per detent) STEP_DEFAULT := 0.5 ; step used until picked / when the ini is invalid ; Analog-pot model: a real pot sweeps 0-100% in ~270 deg. This knob has ~24 ; detents/rev, so 270 deg = ~18 detents -> fast step = 100/18 = 5.5%/detent. ; Slow turn = the fine StepPct; a rip = pot-like full-range sweep. FAST_STEP := 5.5 ; % per detent during a fast spin (270-deg pot feel) FAST_MS := 80 ; detents closer together than this (ms) = fast spin TIMEOUT := 1000 ; ms visible after the last action DBL_MS := 400 ; max ms between clicks to count as a double HUB_BASE := "http://127.0.0.1:8080" ; MBXHub probe target — Tier-1 enhancement. ; Reachable => rich mode (accurate play state, ; track name). Unreachable => plain system-level. NP_TIMEOUT := 2600 ; ms the now-playing card stays up (longer than the OSD) NP_LIFT := 40 ; extra px to lift the NP card — TUNE THIS if it sits low CARD_SUB := "a0a6b4" ; artist / secondary text color ; ---------------------------------------------------------------------------- iniPath := A_ScriptDir "\volstep.ini" ; Harden against a hand-edited/corrupt ini: non-numeric would throw on "+ 0", ; and a step not in the menu would throw at stepMenu.Check(). Snap to the set. StepPct := IniRead(iniPath, "opts", "StepPct", String(STEP_DEFAULT)) StepPct := IsNumber(StepPct) ? StepPct + 0 : STEP_DEFAULT stepOk := false for c in STEP_CHOICES if (StepPct = c) stepOk := true if !stepOk StepPct := STEP_DEFAULT ClickPlayPause := IniRead(iniPath, "opts", "ClickPlayPause", "1") ClickPlayPause := IsNumber(ClickPlayPause) ? (ClickPlayPause + 0 != 0) : true clicks := 0 playing := false hubUp := false ; Tier-1: is MBXHub reachable? artTmp := A_Temp "\volstep_np_art.jpg" ; downloaded album art ; Anchor panels to the TASKBAR WINDOW itself (Shell_TrayWnd): WinGetPos returns ; coords in the same space Gui.Show uses, so no DPI/monitor math can go wrong. ; Centered on the taskbar's width, sitting just above its top edge. BOTTOM_GAP := 26 ; px between the taskbar top and the panels' bottom edge — THE one knob tbX := 0, tbY := 0, tbW := A_ScreenWidth, tbTop := A_ScreenHeight - 48 try { WinGetPos(&tbX, &tbY, &tbW, &tbH, "ahk_class Shell_TrayWnd") tbTop := tbY } ; ---- compact icon OSD: one row — icon | bar | % ---- g := Gui("+AlwaysOnTop -Caption +ToolWindow", "VolOSD") g.BackColor := PANEL_BG g.MarginX := 14 g.MarginY := 12 g.SetFont("s16", "Segoe MDL2 Assets") g.Add("Text", "vIcon Section w28 h26", Chr(0xE994)) g.SetFont() g.Add("Progress", "vBar x+10 ys+10 w150 h6 c" ACCENT " Background" TRACK " Range0-100", 0) g.SetFont("s12 bold cffffff", "Segoe UI") g.Add("Text", "vLbl x+10 ys+3 w64", "0%") g.Show("AutoSize NoActivate") gw := gh := 0 g.GetPos( , , &gw, &gh) gx := tbX + (tbW - gw) // 2 gy := tbTop - gh - BOTTOM_GAP g.Hide() ; ---- Tier-1: rich now-playing card (art + title + artist + state), fixed 340x84 ---- np := Gui("+AlwaysOnTop -Caption +ToolWindow", "NpCard") np.BackColor := PANEL_BG np.Add("Picture", "vNpArt x0 y0 w84 h84") ; album art, full-bleed left np.SetFont("s13 bold cffffff", "Segoe UI") np.Add("Text", "vNpTitle x100 y18 w228", "") np.SetFont("s10 c" CARD_SUB, "Segoe UI") np.Add("Text", "vNpArtist x100 y42 w228", "") np.SetFont("s13 c" ACCENT, "Segoe MDL2 Assets") np.Add("Text", "vNpState x100 y62 w24 h20", Chr(0xE768)) ; Measure the card EXACTLY like the vol OSD is measured — really shown, GetPos, ; hidden. Hidden-window measurement returned different numbers on this box, which ; is why the two panels disagreed about "center". (Brief startup blink, off-screen.) np.Show("w340 h84 NoActivate x0 y-500") npw := nph := 0 np.GetPos( , , &npw, &nph) np.Hide() npx := tbX + (tbW - npw) // 2 npy := gy + gh - nph - NP_LIFT ; vol-OSD-aligned bottom, lifted by NP_LIFT ; ---- tray UI ---- stepMenu := Menu() for c in STEP_CHOICES stepMenu.Add(c "%", SetStep) tray := A_TrayMenu tray.Delete() tray.Add("Step size", stepMenu) tray.Add("Click = Play/Pause (double = Mute)", ToggleClick) tray.Add() tray.Add("Exit", (*) => ExitApp()) RefreshTray() ; ---- Tier-1: detect MBXHub now + re-check periodically ---- CheckHub() SetTimer(CheckHub, 5000) ; ---- hotkeys (no ~ = swallow the native step) ---- Volume_Up:: AdjustVol(1) Volume_Down:: AdjustVol(-1) Volume_Mute:: OnClick() AdjustVol(dir) { global StepPct, FAST_STEP, FAST_MS static lastTick := 0 if SoundGetMute() SoundSetMute(0) ; spinning unmutes — matches native volume-key behavior ; Analog feel: slow detents use the fine StepPct; a rapid burst rides the ; absolute pot-sweep step (270-deg = full range), like a real volume pot. now := A_TickCount step := (now - lastTick < FAST_MS) ? FAST_STEP : StepPct lastTick := now SoundSetVolume((dir > 0 ? "+" : "-") step) ShowVol() } OnClick() { global ClickPlayPause, clicks, DBL_MS if (!ClickPlayPause) { ; remap off -> plain mute toggle SoundSetMute(-1) ShowMute() return } clicks++ if (clicks = 1) SetTimer(ResolveClick, -DBL_MS) } ResolveClick() { global clicks, playing, hubUp, HUB_BASE n := clicks clicks := 0 if (n >= 2) { ; double -> mute (always system-level) SoundSetMute(-1) ShowMute() return } ; single -> play/pause if (hubUp) { ; Tier-1: drive MBXHub directly. Returns the REAL resulting state, so the ; card is accurate (and no media key is injected -> no SMTC double-delivery). resp := HttpPost(HUB_BASE "/player/playpause") st := JsonStr(resp, "state") if (st != "") { playing := (st = "playing") ; keep the Tier-0 fallback tracker in sync ShowNowPlaying(playing) return } } ; Tier-0 fallback: system media key + locally-tracked icon Send("{Media_Play_Pause}") playing := !playing ShowPlay(playing) } ; ---- OSD renderers ---- ShowVol() { v := SoundGetVolume() ShowOsd(VolGlyph(v), Round(v, 1) "%", Round(v)) } ShowMute() { if SoundGetMute() ShowOsd(Chr(0xE74F), "Muted", 0) ; speaker-with-line else { v := SoundGetVolume() ShowOsd(VolGlyph(v), Round(v, 1) "%", Round(v)) } } ShowPlay(isPlaying) { ShowOsd(isPlaying ? Chr(0xE768) : Chr(0xE769), isPlaying ? "Playing" : "Paused", -1) } ShowOsd(glyph, text, barVal) { global g, gx, gy, TIMEOUT, np np.Hide() ; the two panels share the spot — one at a time g["Icon"].Value := glyph g["Lbl"].Value := text if (barVal < 0) g["Bar"].Visible := false else { g["Bar"].Visible := true g["Bar"].Value := barVal } g.Show("NoActivate x" gx " y" gy) SetTimer(HideOsd, -TIMEOUT) } HideOsd() { global g g.Hide() } VolGlyph(v) { if (v <= 0) return Chr(0xE992) ; no waves else if (v <= 33) return Chr(0xE993) ; one else if (v <= 66) return Chr(0xE994) ; two else return Chr(0xE995) ; full } ; ---- tray handlers ---- SetStep(itemName, *) { global StepPct StepPct := StrReplace(itemName, "%", "") + 0 SaveIni() RefreshTray() } ToggleClick(*) { global ClickPlayPause ClickPlayPause := !ClickPlayPause SaveIni() RefreshTray() } RefreshTray() { global stepMenu, tray, StepPct, ClickPlayPause, STEP_CHOICES for c in STEP_CHOICES stepMenu.Uncheck(c "%") stepMenu.Check(StepPct "%") if (ClickPlayPause) tray.Check("Click = Play/Pause (double = Mute)") else tray.Uncheck("Click = Play/Pause (double = Mute)") } SaveIni() { global iniPath, StepPct, ClickPlayPause IniWrite(StepPct, iniPath, "opts", "StepPct") IniWrite(ClickPlayPause, iniPath, "opts", "ClickPlayPause") } ; ---- Tier-1: MBXHub hook (all guarded; failure silently degrades to Tier-0) ---- ShowNowPlaying(isPlaying) { global np, g, HUB_BASE, artTmp, NP_TIMEOUT, npx, npy, gy, gh g.Hide() ; the two panels share the spot — one at a time j := HttpGet(HUB_BASE "/nowplaying") title := JsonStr(j, "title") np["NpTitle"].Value := (title != "") ? title : "MBXHub" np["NpArtist"].Value := JsonStr(j, "artist") np["NpState"].Value := isPlaying ? Chr(0xE768) : Chr(0xE769) ; play / pause if LoadArt() { try np["NpArt"].Value := artTmp } else { try np["NpArt"].Value := "" ; no art for THIS track — never show the previous track's } np.Show("NoActivate w340 h84 x" npx " y" npy) SetTimer(HideNp, -NP_TIMEOUT) } HideNp() { global np np.Hide() } LoadArt() { ; GET /nowplaying/artwork -> temp file (ADODB.Stream). Guarded: any failure ; returns false and the card just keeps the previous art. global HUB_BASE, artTmp try { r := ComObject("WinHttp.WinHttpRequest.5.1") r.SetTimeouts(500, 500, 500, 1500) r.Open("GET", HUB_BASE "/nowplaying/artwork", false) r.Send() if (r.Status = 200) { s := ComObject("ADODB.Stream") s.Type := 1 ; binary s.Open() s.Write(r.ResponseBody) s.SaveToFile(artTmp, 2) ; adSaveCreateOverWrite s.Close() return true } } return false } CheckHub() { global hubUp, HUB_BASE hubUp := (HttpGet(HUB_BASE "/system/version") != "") } HttpGet(url) { try { r := ComObject("WinHttp.WinHttpRequest.5.1") r.SetTimeouts(500, 500, 500, 800) ; resolve, connect, send, receive r.Open("GET", url, false) r.Send() if (r.Status = 200) return r.ResponseText } return "" } HttpPost(url) { try { r := ComObject("WinHttp.WinHttpRequest.5.1") r.SetTimeouts(500, 500, 500, 800) r.Open("POST", url, false) r.Send() if (r.Status = 200) return r.ResponseText } return "" } JsonStr(json, key) { ; POC-grade extractor for a top-level "key":"value" string field. ; Handles escaped chars inside the value (\" \\ \/), so titles with ; quotes or backslashes don't truncate at the first escape. if RegExMatch(json, '"' key '"\s*:\s*"((?:[^"\\]|\\.)*)"', &m) { s := m[1] s := StrReplace(s, '\"', '"') s := StrReplace(s, '\/', '/') s := StrReplace(s, '\\', '\') return s } return "" } GetAccentColor() { try { c := RegRead("HKCU\Software\Microsoft\Windows\CurrentVersion\DWM", "AccentColor") return Format("{:02x}{:02x}{:02x}", c & 0xFF, (c >> 8) & 0xFF, (c >> 16) & 0xFF) ; ABGR -> RGB } return "4cc2ff" }