Volume OSD
A card near the bottom of the focused monitor that shows the volume for a moment whenever it
changes, whether from a media key, wpctl or a mixer. It fades and slides in, then out.
-- The last change worth showing. `n` makes every write differ: `:set` skips a table equal to the held one.
local osd = state("volume_osd", { volume = 0, muted = false, n = 0 })
local function default_sink(audio)
for _, sink in ipairs(audio.sinks) do
if sink.active then
return sink
end
end
end
mantle.audio:on_change(function(audio, previous)
local sink, before = default_sink(audio), previous and default_sink(previous)
if before == nil or sink == nil or sink.id ~= before.id or before.volume == nil or sink.volume == nil then
return -- the first push, or a new default, is learned state, not a change
end
if sink.volume ~= before.volume or sink.muted ~= before.muted then
osd:set({ volume = sink.volume, muted = sink.muted, n = osd:get().n + 1 })
end
end)
local shown = pulse(osd, 1500) -- true for 1.5 s after each change
local mapped = pulse(osd, 1700) -- keeps the surface up while the card fades out
local function percent(entry)
return math.floor(math.min(entry.volume, 100) + 0.5)
end
local glyph = osd:map(function(entry)
if entry.muted or entry.volume == 0 then
return "audio-volume-muted-symbolic"
end
local level = percent(entry)
return level < 34 and "audio-volume-low-symbolic"
or level < 67 and "audio-volume-medium-symbolic"
or "audio-volume-high-symbolic"
end)
return {
panel {
id = "volume_osd",
layer = "overlay",
output = "active",
anchor = { bottom = true }, -- no left/right: centred, width measured
margin = { bottom = 68 },
padding = { bottom = 12 }, -- room for the card's 12 px slide, which the surface would clip
visible = mapped,
child = row {
width = 280,
height = 48,
spacing = 12,
padding = { left = 16, right = 16 },
align_v = "center",
radius = 24,
background = "#1e1e2ee6",
border_width = 1,
border_color = "#45475a",
opacity = shown:map(function(on) return on and 1 or 0 end),
translate = shown:map(function(on) return { y = on and 0 or 12 } end),
animate = {
opacity = { duration = 150, from = 0 },
translate = { duration = 200, easing = "out_cubic", from = { y = 12 } },
},
children = {
icon { name = glyph, size = 20, foreground = "#cdd6f4", align_v = "center" },
rect {
width = "fill",
height = 6,
radius = 3,
align_v = "center",
background = "#45475a",
children = {
rect {
height = "fill",
radius = 3,
background = osd:map(function(entry) return entry.muted and "#6c7086" or "#89b4fa" end),
width = osd:map(function(entry) return percent(entry) .. "%" end),
animate = { width = { duration = 120 } },
},
},
},
text {
content = osd:map(function(entry) return entry.muted and "Muted" or percent(entry) .. "%" end),
width = 44,
text_align = "end",
align_v = "center",
font_size = 13,
foreground = "#cdd6f4",
},
},
},
},
}
Bind the volume keys to anything that changes the default sink, for example
wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+; the OSD follows the push.
How it works
on_changereacts to each audio push and follows theactivesink. It skips the first push, a new default and a sink’s first volume report, which are learned state (on_change, audio).- It writes a fresh table, counted by
n, into a named state;pulsereadstruefor a while after each change (pulse). - A longer second
pulsekeeps the surface mapped while the card fades, since hiding a surface plays no exit (delay is the general form). output = "active"shows it on the output the compositor picks, usually the focused one, and a bottom-only anchor centres it (panel output, OSD).- The fill is a
"NN%"width inside a fixed track (sizes);translateandopacityanimate without re-laying out (animation). - The glyph comes from the icon theme by name (icon).
Variations
| Change | Edit |
|---|---|
| Brightness too | A second on_change on mantle.brightness writing { volume = brightness.percent, muted = false } into the same state |
| Show above 100% | Drop math.min from percent, and size the fill math.floor(entry.volume / 1.5) .. "%": the track is then 150% |
| Top of the screen | anchor = { top = true }, margin = { top = 80 } and from = { y = -12 } |
| Every monitor | Remove output = "active" |
| Longer on screen | pulse(osd, 3000) and pulse(osd, 3200) |