feat(ui): refine listening controls and styling

This commit is contained in:
nvrl committed 2026-11-04 16:18:19 +01:00
1 parent b01403af6b
commit cc53fbe911
5 files changed
+71 -44

No files matched your search

+6 -6
View File
@@ -105,7 +105,8 @@
<form id="filters" aria-labelledby="filters-title">
<h2 id="filters-title">filters and settings</h2>
<fieldset>
<legend>filters</legend>
<legend>feed filters</legend>
<p class="setting-note">Apply these changes to refresh your feed.</p>
<label class="switch"><input type="checkbox" name="free"> <span>free downloads only</span></label>
<div class="bpm">
<div class="bpm-head"><span>bpm</span><output id="bpm-out">any</output></div>
@@ -125,6 +126,7 @@
<button type="button" id="filters-clear" class="link">clear</button>
</div>
</fieldset>
<div class="settings-divider"><h3>listening preferences</h3><p class="setting-note">Changes take effect immediately.</p></div>
<fieldset class="modes">
<legend>visuals</legend>
<label><input type="radio" name="visuals" value="liquid"> liquid</label>
@@ -264,7 +266,6 @@
<dt>← →</dt><dd>seek 10 s</dd>
<dt>f</dt><dd>follow artist</dd>
<dt>r</dt><dd>artist radio</dd>
<dt>x</dt><dd>not for me</dd>
<dt>s</dt><dd>share</dd>
<dt>v</dt><dd>liquid / simple visuals</dd>
<dt>z</dt><dd>zen: just the drop</dd>
@@ -278,7 +279,7 @@
<p>Drag the liquid artwork sideways to spin it. On riddim it turns to ferrofluid: spikes
stand up with the bass, and each kick raises the same crown every bar. Lock-screen and
headphone buttons work too.</p>
<p class="pointer-only fineprint">Keys like r or x doing something else, such as reloading or
<p class="pointer-only fineprint">Keys like r doing something else, such as reloading or
closing the tab? A vim-keys browser extension (Vimium and the like) takes them first:
turn it off for this site.</p>
<button type="button" id="help-close">got it</button>
@@ -308,14 +309,13 @@
<div class="chips"></div>
<p class="desc"></p>
<div class="wave-wrap">
<canvas class="wave"></canvas>
<canvas class="wave" aria-hidden="true"></canvas>
<span class="wave-tip"></span>
</div>
<div class="time"><span class="cur">0:00</span><button type="button" class="to-drop" title="jump to the drop (d)">drop <svg><use href="#i-down"/></svg></button><span class="counts"></span><span class="dur"></span></div>
<div class="player-controls"><button type="button" class="play-toggle" aria-label="pause"><svg aria-hidden="true"><use href="#i-pause"/></svg></button><div class="seek-controls"><input class="seek" type="range" min="0" max="0" value="0" step="1" aria-label="seek track" disabled><div class="time"><span class="cur">0:00</span><button type="button" class="to-drop" title="jump to the next marked drop (d)">drop <svg><use href="#i-down"/></svg></button><span class="counts"></span><span class="dur"></span></div></div></div>
</div>
<nav class="actions">
<button type="button" class="act like" title="like (l)" aria-label="like"><span class="ring"><svg><use href="#i-heart"/></svg></span><span class="n saves"></span></button>
<button type="button" class="act dislike" title="not for me (x)" aria-label="not for me"><span class="ring"><svg><use href="#i-x"/></svg></span><span class="n">nope</span></button>
<button type="button" class="act share" title="share (s)" aria-label="share"><span class="ring"><svg><use href="#i-share"/></svg></span><span class="n">share</span></button>
<a class="act open" target="_blank" rel="noopener" title="open on pl8list" aria-label="open on pl8list"><span class="ring"><svg><use href="#i-comment"/></svg></span><span class="n comments"></span></a>
<button type="button" class="act next" title="next (j)" aria-label="next"><span class="ring"><svg><use href="#i-down"/></svg></span><span class="n">next</span></button>
+24 -3
View File
@@ -7,7 +7,7 @@ import { WaveComments } from "./comments";
export interface CardActions {
like(card: Card): void;
dislike(card: Card): void;
togglePlayback(card: Card): void;
share(card: Card): void;
next(card: Card): void;
follow(card: Card): void;
@@ -191,7 +191,9 @@ export class Card {
$(".comments").textContent = compact(t.comments);
$(".like").addEventListener("click", () => this.actions.like(this));
$(".dislike").addEventListener("click", () => this.actions.dislike(this));
$(".play-toggle").addEventListener("click", () => this.actions.togglePlayback(this));
const seek = $<HTMLInputElement>(".seek");
seek.addEventListener("input", () => { if (this.actions.canSeek(this)) this.actions.seek(this, Number(seek.value)); });
$(".share").addEventListener("click", () => this.actions.share(this));
$(".next").addEventListener("click", () => this.actions.next(this));
$(".follow").addEventListener("click", () => this.actions.follow(this));
@@ -230,6 +232,7 @@ export class Card {
const btn = this.el?.querySelector(".like");
if (!btn) return;
btn.classList.toggle("on", this.liked);
btn.setAttribute("aria-pressed", String(this.liked));
if (pop && this.liked) {
btn.classList.remove("pop");
void (btn as HTMLElement).offsetWidth; // restart the animation
@@ -242,6 +245,7 @@ export class Card {
if (!btn) return;
const on = !!this.track.followed;
btn.classList.toggle("on", on);
btn.setAttribute("aria-pressed", String(on));
btn.querySelector("span")!.textContent = on ? "following" : "follow";
btn.querySelector("use")!.setAttribute("href", on ? "#i-check" : "#i-plus");
}
@@ -249,6 +253,21 @@ export class Card {
setTime(seconds: number): void {
const cur = this.el?.querySelector(".cur");
if (cur) cur.textContent = fmtTime(seconds);
const seek = this.el?.querySelector<HTMLInputElement>(".seek");
if (seek) {
const duration = this.actions.duration(this);
seek.disabled = !this.actions.canSeek(this) || !Number.isFinite(duration);
seek.max = String(Number.isFinite(duration) ? duration : 0);
seek.value = String(seconds);
seek.setAttribute("aria-valuetext", `${fmtTime(seconds)} of ${fmtTime(Number.isFinite(duration) ? duration : 0)}`);
}
}
setPlaying(playing: boolean): void {
const button = this.el?.querySelector<HTMLButtonElement>(".play-toggle");
if (!button) return;
button.setAttribute("aria-label", playing ? "pause" : "play");
button.querySelector("use")?.setAttribute("href", playing ? "#i-pause" : "#i-play");
}
private lastProgress = 0;
@@ -294,7 +313,7 @@ export class Card {
const v = level(i);
const x = i * BAR;
const top = Math.max(2, v * (base - 2));
const color = i < played ? accent : i < loaded ? "rgba(255,255,255,0.5)" : "rgba(255,255,255,0.22)";
const color = i < played ? accent : i < loaded ? "rgba(255,255,255,0.7)" : "rgba(255,255,255,0.42)";
// The bar, standing on the line; played ones glow.
ctx.globalAlpha = 1;
ctx.fillStyle = i < played ? grad : color;
@@ -312,6 +331,8 @@ export class Card {
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.fillStyle = "#fff";
ctx.fillRect(Math.min(w - 2, Math.max(0, progress * w)), 5, 2, base - 5);
// A small mark over each drop.
for (const d of this.drops) {
const x = Math.round(d * bars) * BAR + (BAR - 1) / 2;
+9 -20
View File
@@ -202,6 +202,8 @@ function activate(idx: number): void {
const card = cards[idx];
if (pager.index !== idx) pager.go(idx, { instant: true });
card.element().classList.add("current");
card.setPlaying(false);
card.setTime(0);
visual.attach(card);
if (waveComments) card.loadComments();
if (started) play(card.track);
@@ -260,7 +262,7 @@ upNext.addEventListener("click", () => go(active + 1));
const actions: CardActions = {
like: (c) => setLike(c, !c.liked),
dislike: (c) => dislike(c),
togglePlayback: (c) => { if (c === current() && started) player.toggle(); },
share: (c) => share(c),
next: (c) => go(cards.indexOf(c) + 1),
follow: (c) => setFollow(c, !c.track.followed),
@@ -299,22 +301,6 @@ function nudgeSignIn(): void {
toast("sign in with pl8list to keep these, and bring your library along", 8000, { label: "sign in", run: () => showLogin() });
}
function dislike(card: Card): void {
api.sendEvent(card.track, "dislike");
if (card === current()) reported = true; // a dislike says more than a skip
const wasLiked = card.liked;
if (wasLiked) setLike(card, false);
go(cards.indexOf(card) + 1);
toast("skipped, less of this", 5000, {
label: "undo",
run: () => {
api.sendEvent(card.track, "undislike");
if (wasLiked) setLike(card, true);
go(cards.indexOf(card));
},
});
}
async function setFollow(card: Card, follow: boolean): Promise<void> {
const { handle, artist_name } = card.track;
// Every card by this artist changes together.
@@ -429,10 +415,12 @@ player.on("ended", () => {
player.on("play", () => {
current()?.el?.classList.remove("paused");
current()?.setPlaying(true);
visual.start();
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "playing";
});
player.on("pause", () => {
current()?.setPlaying(false);
if (!player.audio.ended) current()?.el?.classList.add("paused");
current()?.el?.classList.remove("buffering");
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "paused";
@@ -608,7 +596,7 @@ function start(): void {
if (!localStorage.getItem(HINT_KEY)) {
localStorage.setItem(HINT_KEY, "1");
toast(isTouch ? "tap to pause · double-tap to like · swipe up for next"
: "click to pause · double-click to like · ? for shortcuts", 5000);
: "click to pause · double-click to like · ? for shortcuts", 8000, { label: "got it", run: () => {} });
}
}
startEl.addEventListener("click", (e) => {
@@ -656,6 +644,7 @@ function trackRow(t: Track, sub: string, extra?: HTMLElement): HTMLLIElement {
a.target = "_blank";
a.rel = "noopener";
a.textContent = cleanTitle(t.title);
a.title = cleanTitle(t.title);
const by = document.createElement("span");
by.textContent = sub;
meta.append(a, by);
@@ -1033,13 +1022,14 @@ showFilters();
// Keyboard --------------------------------------------------------------
document.addEventListener("keydown", (e) => {
if ((e.target as Element).tagName === "INPUT" || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (k === "Escape") {
setZen(false);
station.close();
return;
}
if ((e.target as Element).closest("input, button, a, select, textarea")) return;
if (k === "?") { sheets.help.toggle(); return; }
if (station.isOpen || Sheet.current || !loginEl.hidden) return;
if (!started) {
@@ -1060,7 +1050,6 @@ document.addEventListener("keydown", (e) => {
else if (k === "l" && card) setLike(card, !card.liked);
else if (k === "f" && card) setFollow(card, !card.track.followed);
else if (k === "r" && card) actions.radio(card);
else if (k === "x" && card) dislike(card);
else if (k === "s" && card) share(card);
else if (k === "h") sheets.history.open();
else if (k === "c") sheets.filters.open();
+30 -12
View File
@@ -4,8 +4,8 @@
color-scheme: dark;
--bg: #000;
--fg: #f2f2f2;
--muted: #9a9a9a;
--faint: #666;
--muted: #b5b5b8;
--faint: #96969a;
--accent: #ff4d6d;
--panel: rgba(18, 18, 20, 0.94);
--line: #ffffff1f;
@@ -18,7 +18,7 @@
--radius-l: 18px;
--shadow: 0 16px 40px #000a;
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: var(--font);
--font-display: "Arial Narrow", "Roboto Condensed", "Liberation Sans Narrow", sans-serif;
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
--fast: 0.15s;
}
@@ -232,6 +232,9 @@ body.zen #bar { display: none; }
#sleep-left { text-transform: none; letter-spacing: 0; color: var(--accent); }
#spikes-setting legend small { text-transform: none; letter-spacing: 0; color: var(--muted); font-weight: 400; }
.sheet-actions { display: flex; align-items: center; gap: 1rem; }
.setting-note { margin: 0; color: var(--muted); font-size: 0.8rem; }
.settings-divider { border-top: 1px solid var(--line-strong); padding-top: 1rem; }
.settings-divider h3 { margin: 0 0 0.25rem; color: var(--fg); }
.sheet-tools { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.sheet-tools input[type=search] {
flex: 1 1 10rem;
@@ -335,7 +338,7 @@ body.zen #bar { display: none; }
}
.presets { display: flex; gap: 0.4rem; }
.presets button { font-size: 0.8rem; padding: 0.25em 0.8em; }
.presets button[aria-pressed="true"] { background: var(--accent); border-color: transparent; color: #fff; }
.presets button[aria-pressed="true"] { background: var(--accent); border-color: transparent; color: #111; }
/* Likes and history: artwork that plays, title, artist. */
.track-list { margin: 0; padding: 0; list-style: none; color: var(--muted); }
@@ -349,7 +352,7 @@ body.zen #bar { display: none; }
}
.track-row:last-child { border-bottom: none; }
.track-row .meta { min-width: 0; flex: 1; }
.track-row .meta a { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; color: var(--fg); }
.track-row .meta a { display: block; overflow-wrap: anywhere; text-decoration: none; color: var(--fg); }
.track-row .meta a:hover { text-decoration: underline; }
.track-row .meta span { font-size: 0.8rem; }
.row-play {
@@ -365,8 +368,8 @@ body.zen #bar { display: none; }
place-items: center;
overflow: hidden;
}
.row-play::before { content: ""; position: absolute; inset: 0; background: #0006; opacity: 0; transition: opacity var(--fast); }
.row-play svg { position: relative; width: 1rem; height: 1rem; opacity: 0; transition: opacity var(--fast); }
.row-play::before { content: ""; position: absolute; inset: 0; background: #000a; opacity: 0.75; transition: opacity var(--fast); }
.row-play svg { position: relative; width: 1rem; height: 1rem; opacity: 1; transition: opacity var(--fast); }
.row-play:hover::before, .row-play:focus-visible::before, .row-play:hover svg, .row-play:focus-visible svg { opacity: 1; }
@media not ((hover: hover) and (pointer: fine)) {
.row-play::before { opacity: 0.6; }
@@ -565,6 +568,7 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
margin: 0;
overflow-wrap: anywhere;
text-wrap: balance;
font-stretch: condensed;
}
.why {
margin: 0;
@@ -632,6 +636,12 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
.wave-wrap { position: relative; width: 100%; margin-top: 0.6rem; }
.wave { display: block; width: 100%; height: 64px; cursor: pointer; touch-action: none; }
.player-controls { display: flex; align-items: center; gap: 0.75rem; width: 100%; }
.play-toggle { flex: none; width: 2.75rem; height: 2.75rem; padding: 0; display: grid; place-items: center; border-radius: 8px; background: var(--fg); color: #111; }
.play-toggle svg { width: 1.35rem; height: 1.35rem; }
.seek-controls { min-width: 0; flex: 1; }
.seek { display: block; width: 100%; height: 1rem; margin: 0 0 0.2rem; accent-color: var(--accent); cursor: pointer; }
.seek:disabled { opacity: 0.5; cursor: default; }
.wave-tip {
position: absolute;
bottom: calc(100% + 4px);
@@ -811,7 +821,7 @@ body.no-wave-comments :is(.wave-comments, .wc-bubble), body.zen :is(.wave-commen
height: 3rem;
display: grid;
place-items: center;
border-radius: 50%;
border-radius: 12px;
background: rgba(255, 255, 255, 0.16);
transition: background var(--fast), transform var(--fast);
}
@@ -1078,7 +1088,7 @@ body.zen #up-next { display: none; }
button.primary {
background: var(--accent);
border: none;
color: #fff;
color: #111;
font-weight: 600;
padding: 0.7em 1.2em;
display: inline-flex;
@@ -1163,11 +1173,11 @@ button.primary:disabled { opacity: 0.35; cursor: default; }
.login-links { display: flex; justify-content: space-between; }
.login-box label.check { display: flex; gap: 0.6rem; align-items: flex-start; color: var(--fg); cursor: pointer; }
.check input { margin-top: 0.2rem; accent-color: var(--accent); }
.check small { display: block; margin-top: 0.2rem; color: #777; font-size: 0.75rem; line-height: 1.4; }
.check small { display: block; margin-top: 0.2rem; color: #aaa; font-size: 0.75rem; line-height: 1.4; }
#login-cancel { align-self: center; }
.link { background: none; border: none; padding: 0; color: var(--muted); text-decoration: underline; font-size: 0.85rem; }
#login-error { margin: 0; min-height: 1.2em; color: var(--accent); font-size: 0.9rem; }
.fineprint { margin: 0; color: #666; font-size: 0.75rem; }
.fineprint { margin: 0; color: #aaa; font-size: 0.75rem; }
/* Phones ----------------------------------------------------------------- */
@@ -1213,7 +1223,7 @@ button.primary:disabled { opacity: 0.35; cursor: default; }
border-radius: 999px;
background: #fff5;
}
.content { width: calc(100vw - 2rem); margin-bottom: 5.5rem; }
.content { width: calc(100vw - 2rem); margin-bottom: 6.5rem; gap: 0.6rem; }
.actions {
right: auto;
left: 50%;
@@ -1226,6 +1236,14 @@ button.primary:disabled { opacity: 0.35; cursor: default; }
#toast { bottom: calc(max(0.8rem, env(safe-area-inset-bottom)) + 8rem); }
}
@media (max-width: 640px) and (max-height: 700px) {
.art { width: min(13rem, 27dvh, 55vw); margin-bottom: 0.15rem; }
.content { gap: 0.3rem; margin-top: 4rem; }
.wave-wrap { margin-top: 0; }
.wave { height: 48px; }
.desc { display: none; }
}
/* Zen: nothing but the drop, bigger, and the music. */
body.zen #bar, body.zen .actions, body.zen .chips, body.zen .desc, body.zen .counts,
body.zen .why, body.zen .byline button, body.zen #feed-status { display: none; }