feat: add chart-based music discovery

This commit is contained in:
nvrl committed 2026-11-04 16:31:26 +01:00
1 parent cc53fbe911
commit 52ce136b66
11 files changed
+189 -22

No files matched your search

+8 -3
View File
@@ -30,8 +30,8 @@ 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 and follows shape what
comes next. Four feeds: for you, riddim, fresh and deep cuts.
- **Discovery.** A personalized feed, new tracks, deep cuts, and pl8list's best-seller
and free-download charts for this week, this year, or all time.
- **Artist radio.** Start a radio from any artist: them and their neighbours in the artist
graph, with a reason on each track.
- **Liquid artwork.** A WebGL drop that moves with the kicks, snares and wobble of the track.
@@ -176,7 +176,9 @@ visitor address reaches pl8list or a CDN.
| | ? · Esc | help · close panels |
Lock-screen, notification and headphone buttons control playback too (Media Session API).
The tabs in the top bar pick the feed (for you, riddim, fresh, deep cuts). "likes" lists your
The tabs in the top bar pick the feed (for you, charts, new, deep cuts). Charts keep
pl8list's ordering and include tracks you've heard before. Tempo presets include half and
double time; custom BPM ranges match the exact value. "likes" lists your
likes newest first, with search; "play" queues them in order or shuffled, and ▶ plays one
right away. "history" lists what played lately, kept on the device only. The filters panel
holds the filters and the settings: visuals, ferrofluid spikes, crossfade, bass boost,
@@ -266,6 +268,9 @@ row. Weights live in a `Ranker`, which can also narrow the candidates and add a
| `deep` | tracks with 15 saves or fewer; popularity counts against a track |
| `artist&artist=<handle>` | artist radio: the artist and their neighbours in the artist graph (two hops) |
`/local/charts?kind=sales|free&period=week|year|all-time` returns the corresponding
public pl8list chart in its original order, independently of served-track history.
In an artist radio each track carries `why`: `seed`, `related` (a neighbour), or `via` with the
neighbour that leads to a second-hop artist. `/local/artist?handle=` returns an artist, their
top tags and closest neighbours for the radio sheet, and starts crawling the neighbourhood
+31 -1
View File
@@ -12,7 +12,7 @@ use serde_json::{Value, json};
use tokio::time::Instant;
use super::{App, CurrentUser, Res, St, json_error, no_store};
use crate::{db, ranker, sync};
use crate::{db, pl8::normalize, ranker, sync};
const EVENT_KINDS: &[&str] = &["play", "like", "unlike", "skip", "complete", "dislike", "undislike"];
/// On a cold start the crawler needs a few seconds before there is anything to serve.
@@ -65,6 +65,35 @@ fn num(q: &HashMap<String, String>, name: &str) -> Option<f64> {
q.get(name).and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite())
}
pub async fn charts(State(app): St, Extension(current): Extension<CurrentUser>, Query(q): Query<HashMap<String, String>>) -> Res {
let user = match reader(&app, &current) { Ok(u) => u, Err(r) => return Ok(r) };
let period = q.get("period").map(String::as_str).unwrap_or("week");
if !matches!(period, "week" | "year" | "all-time") { return Ok(json_error(StatusCode::BAD_REQUEST, "unknown chart period")); }
let kind = q.get("kind").map(String::as_str).unwrap_or("sales");
let section = match kind { "sales" => "bestSellers", "free" => "recommended", _ => return Ok(json_error(StatusCode::BAD_REQUEST, "unknown chart")) };
let data = match app.pl8.charts(period).await? { Some(d) => d, None => return Ok(json_error(StatusCode::BAD_GATEWAY, "chart unavailable")) };
let Some(items) = data.get(section).and_then(|v| v.get("items")).and_then(Value::as_array) else {
return Ok(json_error(StatusCode::BAD_GATEWAY, "chart unavailable"));
};
let rows: Vec<_> = items.iter().enumerate().filter_map(|(i, item)| normalize::item(item).map(|t| (i + 1, t))).collect();
let all: Vec<_> = rows.iter().map(|(_, t)| t.clone()).collect();
let uid = user.id;
let mut tracks = app.db.call(move |c| {
db::upsert_tracks(c, &all, None)?;
let mut out = Vec::new();
for (rank, t) in rows {
if !t.full_access && t.preview_upload_id.is_none() { continue; }
if let Some(mut v) = ranker::track_by_path(c, &t.handle, &t.slug)? {
v["chart_rank"] = json!(rank);
out.push(v);
}
}
Ok(out)
}).await?;
mark_followed(&app, uid, &mut tracks).await?;
Ok((no_store(), Json(json!({ "tracks": tracks }))).into_response())
}
pub async fn feed(State(app): St, Extension(current): Extension<CurrentUser>, Query(q): Query<HashMap<String, String>>) -> Res {
let user = match reader(&app, &current) {
Ok(u) => u,
@@ -83,6 +112,7 @@ pub async fn feed(State(app): St, Extension(current): Extension<CurrentUser>, Qu
free_only: q.get("free").map(String::as_str) == Some("1"),
bpm_min: num(&q, "bpm_min"),
bpm_max: num(&q, "bpm_max"),
bpm_equiv: q.get("bpm_equiv").map(String::as_str) == Some("1"),
};
resync_if_due(&app, &current).await;
let deadline = Instant::now() + FEED_WAIT;
+1
View File
@@ -352,6 +352,7 @@ pub fn router(app: Arc<App>) -> Router {
.route("/auth/guest", post(auth::guest))
.route("/local/me", get(app::me))
.route("/local/feed", get(app::feed))
.route("/local/charts", get(app::charts))
.route("/local/likes", get(app::likes))
.route("/local/track", get(app::track))
.route("/local/artist", get(app::artist))
+30
View File
@@ -382,6 +382,36 @@ async fn token_rotation_keeps_the_pl8list_connection() {
assert!(old_blob.is_none());
}
#[tokio::test]
async fn charts_keep_upstream_order_and_include_previously_served_tracks() {
let upstream = MockServer::start().await;
let item = |id: i64| serde_json::json!({
"track": { "id": id, "slug": format!("s{id}"), "title": format!("track {id}"), "isPublic": true,
"user": { "handle": "artist", "name": "Artist" }, "price": 0 },
"hasFullAccess": true
});
Mock::given(method("GET")).and(path("/best-sellers"))
.and(query_param("period", "year")).and(query_param("downloads-period", "year"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bestSellers": {"items": [item(2), item(1)]}, "recommended": {"items": [item(1), item(2)]}
}))).mount(&upstream).await;
let (r, app, _) = setup("charts", &upstream.uri()).await;
let token = app.db.call(|c| {
crate::db::upsert_user(c, 5, "tester", None, None)?;
crate::db::add_event(c, 5, 2, "served", None)?;
crate::db::create_session(c, 5)
}).await.unwrap();
let cookie = format!("__Host-pl8inf={token}");
let get = |uri: &str| r.clone().oneshot(req("GET", uri, None, Some(&cookie), "203.0.113.1"));
let body = json_body(get("/local/charts?kind=sales&period=year").await.unwrap()).await;
assert_eq!(body["tracks"][0]["id"], 2);
assert_eq!(body["tracks"][0]["chart_rank"], 1);
assert_eq!(body["tracks"][1]["id"], 1);
let body = json_body(get("/local/charts?kind=free&period=year").await.unwrap()).await;
assert_eq!(body["tracks"][0]["id"], 1);
assert_eq!(get("/local/charts?period=month").await.unwrap().status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn feed_modes_filter_and_tag_tracks() {
let (r, app, _) = setup("modes", "http://127.0.0.1:9").await;
+5
View File
@@ -165,6 +165,11 @@ impl Pl8 {
self.get_json(&["best-selling-artists"], &[], Some("routes/best-selling-artists"), None).await
}
/// The public charts: `period` controls sales, `downloads-period` free downloads.
pub async fn charts(&self, period: &str) -> anyhow::Result<Option<Value>> {
self.get_json(&["best-sellers"], &[("period", period.to_string()), ("downloads-period", period.to_string())], Some("routes/best-sellers"), None).await
}
pub async fn activity_ticker(&self) -> anyhow::Result<Option<Value>> {
self.get_json(&["api", "activity-ticker"], &[], None, None).await
}
+25 -7
View File
@@ -347,6 +347,7 @@ pub struct Filters {
pub free_only: bool,
pub bpm_min: Option<f64>,
pub bpm_max: Option<f64>,
pub bpm_equiv: bool,
}
#[cfg(test)]
@@ -364,13 +365,18 @@ pub fn pick_with(conn: &Connection, user_id: i64, n: usize, f: Filters, ranker:
if f.free_only {
sql.push_str(" AND t.price = 0");
}
if let Some(b) = f.bpm_min {
sql.push_str(" AND t.bpm >= ?");
args.push(Sql::Real(b));
}
if let Some(b) = f.bpm_max {
sql.push_str(" AND t.bpm <= ?");
args.push(Sql::Real(b));
if let (Some(lo), Some(hi), true) = (f.bpm_min, f.bpm_max, f.bpm_equiv) {
sql.push_str(" AND ((t.bpm BETWEEN ? AND ?) OR (t.bpm BETWEEN ? AND ?) OR (t.bpm BETWEEN ? AND ?))");
for b in [lo, hi, lo / 2.0, hi / 2.0, lo * 2.0, hi * 2.0] { args.push(Sql::Real(b)); }
} else {
if let Some(b) = f.bpm_min {
sql.push_str(" AND t.bpm >= ?");
args.push(Sql::Real(b));
}
if let Some(b) = f.bpm_max {
sql.push_str(" AND t.bpm <= ?");
args.push(Sql::Real(b));
}
}
if let Some(c) = ranker.condition() {
sql.push_str(" AND (");
@@ -606,6 +612,18 @@ mod tests {
assert_eq!(tempo_match(None, &[140.0]), 0.0);
}
#[test]
fn tempo_presets_include_half_time_but_custom_ranges_are_exact() {
let mut c = db::memory();
db::upsert_tracks(&mut c, &[track(1, "a", "T", 140.0, 0), track(2, "b", "T", 70.0, 0), track(3, "c", "T", 174.0, 0)], None).unwrap();
let f = Filters { bpm_min: Some(138.0), bpm_max: Some(142.0), bpm_equiv: true, ..Default::default() };
let mut ids: Vec<_> = pick(&c, 1, 10, f, &Classic).unwrap().iter().map(|t| t["id"].as_i64().unwrap()).collect();
ids.sort();
assert_eq!(ids, [1, 2]);
let exact = Filters { bpm_equiv: false, ..f };
assert_eq!(pick(&c, 1, 10, exact, &Classic).unwrap().len(), 1);
}
#[test]
fn unknown_dates_count_as_a_year_old() {
assert_eq!(age_days(None), 365.0);
+12 -6
View File
@@ -42,8 +42,8 @@
<span class="logo"><svg aria-hidden="true"><use href="#i-rewind"/></svg><span class="logo-pl8">pl8list </span><b>rewind</b></span>
<nav id="mode-tabs" aria-label="feed">
<button type="button" data-mode="classic">for you</button>
<button type="button" data-mode="riddim">riddim</button>
<button type="button" data-mode="fresh">fresh</button>
<button type="button" data-mode="charts">charts</button>
<button type="button" data-mode="fresh">new</button>
<button type="button" data-mode="deep">deep cuts</button>
</nav>
<button id="mode-chip" type="button" title="back to your feed" hidden>
@@ -60,6 +60,11 @@
<button id="account-btn" type="button" class="avatar-btn" title="account" aria-label="account" aria-controls="account"></button>
</div>
</header>
<div id="discovery-options" hidden>
<label>chart <select id="chart-kind"><option value="sales">best sellers</option><option value="free">top free downloads</option></select></label>
<label>period <select id="chart-period"><option value="week">this week</option><option value="year">this year</option><option value="all-time">all time</option></select></label>
</div>
<button type="button" id="active-filters" hidden></button>
<div id="login" hidden>
<div class="login-box">
@@ -109,16 +114,17 @@
<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>
<div class="bpm-head"><span>tempo (bpm)</span><output id="bpm-out">any</output></div>
<p class="setting-note">Presets include half and double time; custom ranges use the exact BPM.</p>
<div class="range2">
<input type="range" name="bpm_min" min="60" max="200" step="1" value="60" aria-label="lowest bpm">
<input type="range" name="bpm_max" min="60" max="200" step="1" value="200" aria-label="highest bpm">
</div>
<div class="presets">
<button type="button" data-bpm="60-200">any</button>
<button type="button" data-bpm="138-142">140</button>
<button type="button" data-bpm="148-152">150</button>
<button type="button" data-bpm="165-178">170</button>
<button type="button" data-bpm="138-142">~140</button>
<button type="button" data-bpm="148-152">~150</button>
<button type="button" data-bpm="165-178">~170</button>
</div>
</div>
<div class="sheet-actions">
+6 -2
View File
@@ -24,6 +24,8 @@ export interface Track {
upload_id: string | null;
waveform: number[] | null;
followed?: boolean;
chart_rank?: number;
chart_label?: string;
/// From titles, descriptions and BPM, see backend/src/tags.rs.
tags?: string[];
/// In an artist radio: why this track is in it.
@@ -77,12 +79,12 @@ export interface Me {
pl8list: Pl8Status;
}
export type Filters = Partial<Record<"free" | "bpm_min" | "bpm_max", string>>;
export type Filters = Partial<Record<"free" | "bpm_min" | "bpm_max" | "bpm_equiv", string>>;
/// Which part of the catalogue the feed draws from. `artist` is an artist radio:
/// that artist and the ones pl8list relates to them.
export type Mode =
| { mode: "classic" | "riddim" | "fresh" | "deep" }
| { mode: "classic" | "charts" | "fresh" | "deep" }
| { mode: "artist"; artist: string; name: string; img?: string | null };
export type EventKind = "play" | "like" | "unlike" | "skip" | "complete" | "dislike" | "undislike";
@@ -127,6 +129,8 @@ export function feed(filters: Filters, mode: Mode) {
return getJSON<{ tracks: Track[]; stats: Stats }>("/local/feed?" + p);
}
export const chart = (kind: string, period: string) => getJSON<{ tracks: Track[] }>("/local/charts?" + new URLSearchParams({ kind, period }));
export const artist = (handle: string) => getJSON<ArtistInfo>("/local/artist?" + new URLSearchParams({ handle }));
export const track = (handle: string, slug: string) =>
+1
View File
@@ -219,6 +219,7 @@ export class Card {
/// Where a radio track comes from, e.g. "Lower radio · via Motus".
private whyText(): string {
if (this.track.chart_rank && this.track.chart_label) return `#${this.track.chart_rank} · ${this.track.chart_label} · pl8list`;
const { station } = this;
const why = this.track.why;
if (!station) return "";
+60 -3
View File
@@ -26,6 +26,7 @@ const MODE_KEY = "pl8inf.mode";
const HINT_KEY = "pl8inf.hintShown";
const NUDGE_KEY = "pl8inf.signInNudged";
const COMMENTS_KEY = "pl8inf.waveComments";
const MODE_NAMES = { classic: "for you", charts: "charts", fresh: "new", deep: "deep cuts" };
const NUDGE_LIKES = 5; // a guest is asked to sign in after this many likes, or a first follow
/// Timed comments on the waveform, on unless turned off.
let waveComments = localStorage.getItem(COMMENTS_KEY) !== "0";
@@ -79,6 +80,8 @@ let mode: Mode = loadMode();
let stats: Stats | null = null;
let jumpOnLoad = false; // a radio just started: go to its first track as soon as it is here
let radioDry = false; // the artist radio found nothing more; say so on its last card
let chartLoaded = false;
let presetTempo = false;
/// Playing the likes in order (or shuffled) instead of the feed. Null for the feed.
let queue: { tracks: Track[]; next: number } | null = null;
@@ -104,6 +107,25 @@ async function loadMore(): Promise<void> {
fromQueue();
return;
}
if (mode.mode === "charts") {
if (chartLoaded) { updateChartEnd(); return; }
loading = true;
const gen = feedGen;
const kind = chartKind.value, period = chartPeriod.value;
try {
const data = await api.chart(kind, period);
if (gen !== feedGen) return;
chartLoaded = true;
const label = `${kind === "sales" ? "best sellers" : "top free downloads"} · ${chartPeriod.selectedOptions[0].textContent}`;
const tracks = data.tracks.filter(matchesFilters).map((t) => ({ ...t, chart_label: label }));
if (tracks.length) addCards(tracks.map((t) => new Card(t, actions)));
else { jumpOnLoad = false; setStatus(data.tracks.length ? "no chart tracks match your filters. clear filters to see the chart." : "no playable tracks in this chart right now."); }
updateChartEnd();
} catch (err) {
if (gen === feedGen) setStatus(`couldn't load chart: ${(err as Error).message}`);
} finally { if (gen === feedGen) loading = false; }
return;
}
loading = true;
if (!cards.length) setStatus("crawling pl8list, first tracks in a few seconds…");
const gen = feedGen;
@@ -215,6 +237,7 @@ function activate(idx: number): void {
}, PRELOAD_AFTER_MS);
if (idx >= cards.length - PREFETCH_AHEAD) loadMore();
updateRadioEnd();
updateChartEnd();
showUpNext();
cards.forEach((c, i) => { if (c.el && Math.abs(i - idx) > KEEP_BUILT) c.release(); });
}
@@ -773,6 +796,15 @@ $("history-clear").addEventListener("click", () => {
const bpmMin = filtersForm.elements.namedItem("bpm_min") as HTMLInputElement;
const bpmMax = filtersForm.elements.namedItem("bpm_max") as HTMLInputElement;
const bpmOut = $("bpm-out");
const activeFilters = $<HTMLButtonElement>("active-filters");
function matchesFilters(t: Track): boolean {
if (filters.free === "1" && t.price !== 0) return false;
if (!filters.bpm_min && !filters.bpm_max) return true;
if (t.bpm == null) return false;
const lo = Number(filters.bpm_min || 0), hi = Number(filters.bpm_max || Infinity);
return (filters.bpm_equiv === "1" ? [t.bpm, t.bpm * 2, t.bpm / 2] : [t.bpm]).some((b) => b >= lo && b <= hi);
}
function loadFilters(): Filters {
try {
@@ -800,8 +832,12 @@ function showFilters(): void {
(filtersForm.elements.namedItem("free") as HTMLInputElement).checked = filters.free === "1";
bpmMin.value = filters.bpm_min || String(BPM_RANGE[0]);
bpmMax.value = filters.bpm_max || String(BPM_RANGE[1]);
presetTempo = filters.bpm_equiv === "1";
showBpm();
filtersBtn.classList.toggle("active", Object.keys(filters).length > 0);
const labels = [filters.free === "1" ? "free only" : "", filters.bpm_min || filters.bpm_max ? `${filters.bpm_min || "any"}–${filters.bpm_max || "any"} bpm${filters.bpm_equiv ? " ± half/double" : ""}` : ""].filter(Boolean);
activeFilters.hidden = !labels.length;
activeFilters.textContent = `${labels.join(" · ")} × clear`;
}
function applyFilters(): void {
@@ -809,6 +845,7 @@ function applyFilters(): void {
if ((filtersForm.elements.namedItem("free") as HTMLInputElement).checked) filters.free = "1";
if (Number(bpmMin.value) > BPM_RANGE[0]) filters.bpm_min = bpmMin.value;
if (Number(bpmMax.value) < BPM_RANGE[1]) filters.bpm_max = bpmMax.value;
if (presetTempo && filters.bpm_min && filters.bpm_max) filters.bpm_equiv = "1";
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
showFilters();
Sheet.closeAll();
@@ -818,21 +855,27 @@ function applyFilters(): void {
/// Drop cards after the current one so a new filter or mode applies right away.
function refill(): void {
queue = null;
if (mode.mode === "charts") jumpOnLoad = true;
for (const c of cards.splice(active + 1)) c.release();
pager.refresh();
feedGen++;
chartLoaded = false;
setStatus("");
loading = false;
loadMore();
}
for (const input of [bpmMin, bpmMax]) input.addEventListener("input", showBpm);
for (const input of [bpmMin, bpmMax]) input.addEventListener("input", () => { presetTempo = false; showBpm(); });
for (const b of filtersForm.querySelectorAll<HTMLButtonElement>("[data-bpm]")) {
b.addEventListener("click", () => {
[bpmMin.value, bpmMax.value] = b.dataset.bpm!.split("-");
presetTempo = b.dataset.bpm !== "60-200";
showBpm();
});
}
activeFilters.addEventListener("click", () => { filters = {}; localStorage.setItem(FILTERS_KEY, "{}"); showFilters(); refill(); });
function showSetting(name: string, value: string): void {
for (const input of filtersForm.querySelectorAll<HTMLInputElement>(`input[name=${name}]`)) input.checked = input.value === value;
}
@@ -938,11 +981,13 @@ function showSleep(): void {
const modeChip = $("mode-chip");
const modeTabs = $("mode-tabs");
const MODE_NAMES = { classic: "for you", riddim: "riddim", fresh: "fresh", deep: "deep cuts" };
const chartKind = $<HTMLSelectElement>("chart-kind");
const chartPeriod = $<HTMLSelectElement>("chart-period");
function loadMode(): Mode {
try {
const m = JSON.parse(localStorage.getItem(MODE_KEY) || "null");
if (m?.mode === "riddim") return { mode: "classic" };
if (m?.mode === "artist" ? typeof m.artist === "string" && typeof m.name === "string" : m?.mode in MODE_NAMES) return m;
} catch { /* fall through */ }
return { mode: "classic" };
@@ -955,6 +1000,7 @@ function showMode(): void {
const chip = mode.mode === "artist" || queue !== null;
modeChip.hidden = !chip;
modeTabs.hidden = chip;
$("discovery-options").hidden = chip || mode.mode !== "charts";
for (const b of modeTabs.querySelectorAll<HTMLButtonElement>("button")) {
b.setAttribute("aria-pressed", String(b.dataset.mode === mode.mode));
}
@@ -976,6 +1022,7 @@ function setMode(m: Mode): void {
radioDry = false;
updateRadioEnd();
if (JSON.stringify(m) === JSON.stringify(mode) && !queue) return;
if (m.mode === "charts") jumpOnLoad = true;
mode = m;
queue = null;
localStorage.setItem(MODE_KEY, JSON.stringify(mode));
@@ -987,7 +1034,17 @@ function setMode(m: Mode): void {
modeChip.addEventListener("click", () => setMode({ mode: "classic" }));
for (const b of modeTabs.querySelectorAll<HTMLButtonElement>("button")) {
b.addEventListener("click", () => setMode({ mode: b.dataset.mode as "classic" | "riddim" | "fresh" | "deep" }));
b.addEventListener("click", () => setMode({ mode: b.dataset.mode as "classic" | "charts" | "fresh" | "deep" }));
}
for (const select of [chartKind, chartPeriod]) select.addEventListener("change", () => { jumpOnLoad = true; refill(); });
function updateChartEnd(): void {
if (mode.mode === "charts" && chartLoaded && active >= cards.length - 1 && cards[active]?.track.chart_rank) {
setStatus("end of chart · change the chart or return to your feed");
} else if (mode.mode === "charts" && cards[active]?.track.chart_rank) {
setStatus("");
}
}
const station = new Station((m) => {
+10
View File
@@ -77,6 +77,13 @@ body.zen #bar { display: none; }
.logo b { color: var(--accent); font-weight: 700; }
.logo svg { width: 1.3em; height: 1.3em; color: var(--accent); flex: none; }
.bar-end { justify-self: end; display: flex; align-items: center; gap: 0.4rem; }
#discovery-options, #active-filters { position: fixed; z-index: 11; left: 50%; transform: translateX(-50%); }
#discovery-options { top: 4.2rem; display: flex; gap: 0.5rem; align-items: center; }
#discovery-options label { display: flex; align-items: center; gap: 0.35rem; color: var(--muted); font-size: 0.75rem; }
#discovery-options select { color: var(--fg); background: #18181b; border: 1px solid #ffffff40; border-radius: 8px; padding: 0.45rem; font: inherit; }
#active-filters { top: 7.3rem; padding: 0.35rem 0.7rem; background: #19191de8; border-radius: 8px; font-size: 0.75rem; white-space: nowrap; }
body:has(#discovery-options[hidden]) #active-filters { top: 4.3rem; }
body.zen :is(#discovery-options, #active-filters) { display: none; }
/* Feed modes as segmented tabs, the middle of the bar. */
#mode-tabs {
@@ -1188,6 +1195,9 @@ button.primary:disabled { opacity: 0.35; cursor: default; }
/* Phones have volume buttons; the bar has no room. */
#volume { display: none; }
.logo-pl8 { display: none; }
#discovery-options { top: calc(max(0.6rem, env(safe-area-inset-top)) + 5.8rem); }
#active-filters { top: calc(max(0.6rem, env(safe-area-inset-top)) + 8.5rem); }
body:has(#discovery-options[hidden]) #active-filters { top: calc(max(0.6rem, env(safe-area-inset-top)) + 5.8rem); }
#station { place-items: end stretch; padding: 0; }
.station-box { width: 100%; border-radius: var(--radius-l) var(--radius-l) 0 0; padding-bottom: max(1.4rem, env(safe-area-inset-bottom)); }
.sheet {