123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137 | // Volume widget: SVG speaker whose sound waves fade in proportionally to
// the level; an X replaces the waves when muted. Clicking the chip opens
// a pop-down slider; the slider has a speaker button that toggles mute.
import { createSliderPopup } from "../lib/slider-popup.js";
import {
METER_SETTINGS,
meterValueCss,
makePeek,
segmentFill,
} from "../lib/meter.js";
const SPEAKER_SVG = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<!-- The artwork only spans ~13 of the 24 viewBox units; scale it up
so the icon reads as large as the other widgets' icons. The
scale also multiplies the stroke, hence 1.5 (≈1.8 rendered). -->
<g transform="translate(12 12) scale(1.2) translate(-12 -12)">
<path d="M4 9.5 H7.5 L12 5.5 V18.5 L7.5 14.5 H4 Z"
fill="currentColor" stroke="none"/>
<path class="wave1" d="M14.5 10 Q16.2 12 14.5 14"/>
<path class="wave2" d="M16.3 8.5 Q19 12 16.3 15.5"/>
<path class="wave3" d="M18.1 7 Q21.8 12 18.1 17"/>
<g class="mute" style="display: none">
<path d="M15.5 9.5 L20.5 14.5 M20.5 9.5 L15.5 14.5"/>
</g>
</g>
</svg>
`;
// Updates any speaker SVG (chip icon or popup button) to match the state.
function paintSpeaker(root, volume) {
const waves = [
root.querySelector(".wave1"),
root.querySelector(".wave2"),
root.querySelector(".wave3"),
];
const mute = root.querySelector(".mute");
if (!waves[0]) return;
mute.style.display = volume.muted ? "" : "none";
if (volume.muted) waves.forEach((wave) => (wave.style.opacity = 0));
else segmentFill(waves, volume.percent, 0.15);
}
export default {
name: "volume",
css: `
.widget-volume { cursor: pointer; }
${meterValueCss("widget-volume")}
.widget-volume.muted .value,
.widget-volume.muted .icon { color: var(--bar-fg-dim); }
.widget-volume.muted .value { text-decoration: line-through; }
`,
settings: METER_SETTINGS,
create({ options, onStats, invoke }) {
const el = document.createElement("div");
el.className = "chip clickable widget-volume";
el.classList.toggle("compact", !options.showPercent);
el.innerHTML = `
<span class="icon">${SPEAKER_SVG}</span>
<span class="value">--%</span>
`;
const iconEl = el.querySelector(".icon");
const peek = makePeek(el);
const valueEl = el.querySelector(".value");
let leadBtn = null;
let lastPercent = null;
let lastMuted = null;
function render(volume) {
el.style.display = volume ? "" : "none";
if (!volume) return;
const changed =
lastPercent !== null &&
(volume.percent !== lastPercent || volume.muted !== lastMuted);
// Muting is a silencing gesture; only peek when unmuting or changing level.
const justMuted = volume.muted && !lastMuted;
lastPercent = volume.percent;
lastMuted = volume.muted;
if (changed && !justMuted && !options.showPercent && options.peekOnChange)
peek.show();
valueEl.textContent = `${volume.percent}%`;
el.title = volume.muted ? `Volume: muted` : `Volume: ${volume.percent}%`;
popup.setValue(volume.percent);
el.classList.toggle("muted", volume.muted);
paintSpeaker(iconEl, volume);
if (leadBtn?.isConnected) {
paintSpeaker(leadBtn, volume);
leadBtn.style.color = volume.muted ? "var(--bar-fg-dim)" : "";
}
lastVolume = volume;
}
let lastVolume = null;
const popup = createSliderPopup({
anchor: el,
onChange: async (percent) => {
try {
render(await invoke("set_volume", { percent }));
} catch (err) {
console.error("divebar: set_volume failed", err);
}
},
createLeading: () => {
leadBtn = document.createElement("button");
leadBtn.title = "Toggle mute";
leadBtn.innerHTML = SPEAKER_SVG;
if (lastVolume) {
paintSpeaker(leadBtn, lastVolume);
leadBtn.style.color = lastVolume.muted ? "var(--bar-fg-dim)" : "";
}
leadBtn.addEventListener("click", async () => {
try {
render(await invoke("toggle_mute"));
} catch (err) {
console.error("divebar: toggle_mute failed", err);
}
});
return leadBtn;
},
});
el.addEventListener("click", () => popup.toggle());
const unsubscribe = onStats((stats) => render(stats.volume));
return {
el,
destroy() {
peek.cancel();
popup.close();
unsubscribe();
},
};
},
};
|