added volume control to desktop

This commit is contained in:
nvrl committed 2026-11-01 15:10:03 +01:00
1 parent 2ddc1b5629
commit 482cfc299b
4 files changed
+80 -4

No files matched your search

+7 -1
View File
@@ -26,6 +26,8 @@
<symbol id="i-open" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" d="M7 17 17 7M9 7h8v8"/></symbol>
<symbol id="i-down" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" d="M12 4v16M6 14l6 6 6-6"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path fill="currentColor" d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></symbol>
<symbol id="i-vol" viewBox="0 0 24 24"><path fill="currentColor" d="M4 9h4l5-4.5v15L8 15H4z"/><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M16.5 8.5a5 5 0 0 1 0 7M19 6a8.5 8.5 0 0 1 0 12"/></symbol>
<symbol id="i-mute" viewBox="0 0 24 24"><path fill="currentColor" d="M4 9h4l5-4.5v15L8 15H4z"/><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M16.5 9.5l5 5M21.5 9.5l-5 5"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><path fill="currentColor" d="M7 4.5h3.5v15H7zM13.5 4.5H17v15h-3.5z"/></symbol>
</svg>
@@ -37,6 +39,10 @@
<span id="stats"></span>
<button id="filters-btn" type="button" aria-controls="filters" aria-expanded="false">filters</button>
<button id="likes-btn" type="button" aria-controls="likes" aria-expanded="false">likes</button>
<div id="volume" hidden>
<button id="mute-btn" type="button" title="mute (m)" aria-label="mute" aria-pressed="false"><svg><use href="#i-vol"/></svg></button>
<input id="volume-slider" type="range" min="0" max="1" step="0.01" aria-label="volume">
</div>
<button id="help-btn" type="button" title="shortcuts (?)" aria-label="help">?</button>
<button id="account-btn" type="button" aria-controls="account" aria-expanded="false" title="account"></button>
</header>
@@ -183,7 +189,7 @@
<dt>x</dt><dd>not for me</dd>
<dt>s</dt><dd>share</dd>
<dt>v</dt><dd>liquid / simple visuals</dd>
<dt>m</dt><dd>mute</dd>
<dt>m · + −</dt><dd>mute · volume</dd>
<dt>?</dt><dd>this help</dd>
</dl>
<p>Drag the liquid artwork sideways to spin it. Lock-screen and headphone buttons work too.</p>
+41 -1
View File
@@ -395,6 +395,44 @@ document.addEventListener("pointerdown", (e) => {
setTimeout(() => { swallowClick = false; }, 500);
}, true);
// Volume ------------------------------------------------------------------
const volumeEl = $("volume");
const muteBtn = $("mute-btn");
const volumeSlider = $<HTMLInputElement>("volume-slider");
function showVolume(): void {
const silent = player.muted || player.volume === 0;
muteBtn.querySelector("use")!.setAttribute("href", silent ? "#i-mute" : "#i-vol");
muteBtn.setAttribute("aria-pressed", String(player.muted));
volumeSlider.value = String(player.muted ? 0 : player.volume);
}
function setMuted(m: boolean): void {
player.muted = m;
// Unmuting at zero would stay silent.
if (!m && player.volume === 0) player.volume = 0.5;
showVolume();
}
function setVolume(v: number): void {
player.volume = v;
player.muted = false;
showVolume();
}
// iOS ignores the volume, see player.ts.
volumeEl.hidden = !player.canMix;
showVolume();
muteBtn.addEventListener("click", () => setMuted(!player.muted));
volumeSlider.addEventListener("input", () => setVolume(Number(volumeSlider.value)));
// Hand the keys back to the feed, or space and the arrows would move the slider.
volumeSlider.addEventListener("change", () => volumeSlider.blur());
volumeSlider.addEventListener("wheel", (e) => {
e.preventDefault();
setVolume(player.volume - Math.sign(e.deltaY) * 0.05);
}, { passive: false });
const showHelp = () => { helpEl.hidden = false; };
const hideHelp = () => { helpEl.hidden = true; };
$("help-btn").addEventListener("click", showHelp);
@@ -620,7 +658,9 @@ document.addEventListener("keydown", (e) => {
else if (k === "x" && card) dislike(card);
else if (k === "s" && card) share(card);
else if (k === "v") { setVisuals(visual.visuals === "liquid" ? "simple" : "liquid"); toast(`${visual.visuals} visuals`); }
else if (k === "m") { player.muted = !player.muted; toast(player.muted ? "muted" : "sound on"); }
else if (k === "m") { setMuted(!player.muted); toast(player.muted ? "muted" : "sound on"); }
else if (k === "+" || k === "=") { setVolume(player.volume + 0.1); toast(`volume ${Math.round(player.volume * 100)}%`); }
else if (k === "-") { setVolume(player.volume - 0.1); toast(`volume ${Math.round(player.volume * 100)}%`); }
else if (k === "?") { helpEl.hidden ? showHelp() : hideHelp(); }
});
+26 -2
View File
@@ -2,7 +2,8 @@
// crossfade into each other.
//
// Where the browser allows it, both <audio> elements run through a Web Audio graph:
// a gain per deck (for the crossfade) into one analyser (for the visuals). iOS is
// a gain per deck (for the crossfade) into one analyser (for the visuals), then a
// master gain for the volume, so the visuals look the same at any volume. iOS is
// left out: its element volume is read-only and Web Audio stops when the screen
// locks, which would break lock-screen playback. There tracks switch with a cut.
@@ -12,6 +13,7 @@ import { type Track, streamUrl } from "./api";
const CROSSFADE = 0.9; // seconds
const PRELOAD_BUFFER = 6; // seconds buffered of the track after the current one
const PLAY_BUFFER = 30;
const VOLUME_KEY = "pl8inf.volume";
type Hls = HlsType;
type HlsClass = typeof HlsType;
@@ -68,6 +70,8 @@ export class Player {
private ctx: AudioContext | null = null;
private unlocking: Promise<void> | null = null;
private fadeEnd = 0;
private master: GainNode | null = null;
private vol = Number(localStorage.getItem(VOLUME_KEY) ?? 1);
private listeners = new Map<PlayerEvent, Set<() => void>>();
analyser: AnalyserNode | null = null;
/// False on iOS, see the top of this file.
@@ -84,6 +88,8 @@ export class Player {
return { el, hls: null, gain: null, track: null, wantPlay: false, gen: 0 };
};
this.decks = [deck(), deck()];
if (!(this.vol >= 0 && this.vol <= 1)) this.vol = 1;
this.volume = this.vol;
for (const d of this.decks) {
for (const ev of FORWARDED) {
d.el.addEventListener(ev, () => {
@@ -150,8 +156,10 @@ export class Player {
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.2; // features.ts smooths per feature; this only takes the edge off the FFT
analyser.connect(ctx.destination);
const master = ctx.createGain();
analyser.connect(master).connect(ctx.destination);
for (const d of this.decks) {
d.el.volume = 1; // the master gain takes over
const gain = ctx.createGain();
gain.gain.value = d === this.decks[this.cur] ? 1 : 0;
ctx.createMediaElementSource(d.el).connect(gain).connect(analyser);
@@ -159,6 +167,8 @@ export class Player {
}
this.ctx = ctx;
this.analyser = analyser;
this.master = master;
this.volume = this.vol;
}
/// Play a track, crossfading from the current one when possible.
@@ -282,4 +292,18 @@ export class Player {
set muted(m: boolean) {
for (const d of this.decks) d.el.muted = m;
}
/// 0 to 1, as the slider shows it. Squared on the way out, so it sounds even.
/// Without a graph the element volumes carry it; on iOS they are read-only.
get volume(): number {
return this.vol;
}
set volume(v: number) {
this.vol = Math.max(0, Math.min(1, v));
localStorage.setItem(VOLUME_KEY, String(this.vol));
const out = this.vol * this.vol;
if (this.master) this.master.gain.value = out;
else for (const d of this.decks) d.el.volume = out;
}
}
+6
View File
@@ -70,6 +70,10 @@ button {
.logo b { color: var(--accent); }
#stats { color: var(--muted); font-size: 0.8rem; }
#help-btn { width: 2.2em; padding: 0.35em 0; }
#volume { display: flex; align-items: center; gap: 0.3rem; }
#mute-btn { width: 2.2em; padding: 0.35em 0; display: grid; place-items: center; }
#mute-btn svg { width: 1.1em; height: 1.1em; }
#volume-slider { width: 5.5rem; accent-color: var(--accent); cursor: pointer; }
#account-btn { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#filters-btn.active::after {
content: "";
@@ -699,6 +703,8 @@ button.primary svg { width: 1em; height: 1em; }
#bar { gap: 0.4rem; padding-left: 0.7rem; padding-right: 0.7rem; }
#bar button { padding: 0.3em 0.7em; font-size: 0.85rem; }
#help-btn { display: none; }
/* Phones have volume buttons; the bar has no room. */
#volume { display: none; }
#account-btn { max-width: 6.5rem; }
.panel { left: 0.7rem; right: 0.7rem; }
#station { place-items: end stretch; padding: 0; }