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

+2 -3
View File
@@ -30,7 +30,7 @@ Not affiliated with pl8list.
- **One track per screen.** Swipe, scroll or press ↓. The next track is already buffered and
crossfades in.
- **A feed that learns.** Likes, finished plays, skips, "not for me" and follows shape what
- **A feed that learns.** Likes, finished plays, skips and follows shape what
comes next. Four feeds: for you, riddim, fresh and deep cuts.
- **Artist radio.** Start a radio from any artist: them and their neighbours in the artist
graph, with a reason on each track.
@@ -168,7 +168,6 @@ visitor address reaches pl8list or a CDN.
| drop ↓ button | d | jump to the next drop |
| follow button | f | follow the artist |
| radio button | r | artist radio (more like them) |
| ✕ button | x | not for me (skip, show less) |
| share button | s | share / copy link |
| | v | liquid / simple visuals |
| | z | zen: just the drop |
@@ -253,7 +252,7 @@ one request every 20 seconds. When a user likes or finishes a track, its related
its artist's catalogue are crawled first.
**Ranking** (`backend/src/ranker.rs`). Each track the user has not seen yet gets a score from popularity,
freshness, artist affinity (from their likes, completions, skips, "not for me" and follows), closeness to the BPM and
freshness, artist affinity (from their likes, completions, skips and follows), closeness to the BPM and
key of tracks they liked, whether it is related to something they liked, and some random
exploration. Artists are then spread out so you don't get five tracks from one person in a
row. Weights live in a `Ranker`, which can also narrow the candidates and add a bonus;
+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; }