mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-10 17:07:03 +01:00
feat: add chart-based music discovery
This commit is contained in:
1 parent
cc53fbe911
commit
52ce136b66
11 files changed
+189
-22
No files matched your search
@@ -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
@@ -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, ¤t) { 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, ¤t) {
|
||||
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, ¤t).await;
|
||||
let deadline = Instant::now() + FEED_WAIT;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) =>
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user