diff --git a/js/app.js b/js/app.js
new file mode 100644
index 0000000..4851b14
--- /dev/null
+++ b/js/app.js
@@ -0,0 +1,1305 @@
+"use strict";
+
+const ICONS = {
+ play: ``,
+ pause: ``,
+ next: ``,
+ prev: ``,
+ heart: ``,
+ heartFill: ``,
+ home: ``,
+ search: ``,
+ library: ``,
+ grid: ``,
+ radio: ``,
+ shuffle: ``,
+ repeat: ``,
+ volume: ``,
+ volumeX: ``,
+ queue: ``,
+ plus: ``,
+ x: ``,
+ trash: ``,
+ pencil: ``,
+ dots: ``,
+ music: ``,
+ clock: ``,
+ sparkle: ``,
+ check: ``,
+ chevron: ``
+};
+
+function icon(name, size) {
+ const s = size || 20;
+ const solid = ["play", "pause", "next", "prev", "heartFill", "grid", "sparkle"];
+ const svg = ``;
+ return svg;
+}
+
+function $(sel, root) {
+ return (root || document).querySelector(sel);
+}
+
+function esc(s) {
+ return String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+}
+
+function fmt(sec) {
+ sec = Math.max(0, Math.floor(sec || 0));
+ const m = Math.floor(sec / 60);
+ const s = sec % 60;
+ return m + ":" + (s < 10 ? "0" : "") + s;
+}
+
+function fmtNum(n) {
+ if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + "B";
+ if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
+ if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
+ return String(n);
+}
+
+function fmtDurTotal(sec) {
+ const m = Math.round(sec / 60);
+ const h = Math.floor(m / 60);
+ return h ? h + " hr " + (m % 60) + " min" : m + " minutes";
+}
+
+function eqIcon() {
+ return ``;
+}
+
+const ctxMap = {};
+let prevRoute = "";
+
+Player.onState = function (playing) {
+ updatePlayerUI();
+ updateRows();
+};
+
+Player.onTime = function (cur, dur) {
+ const pct = dur > 0 ? (cur / dur) * 100 : 0;
+ $("#pb-fill").style.width = pct + "%";
+ $("#pb-cur").textContent = fmt(cur);
+ $("#pb-dur").textContent = fmt(dur || Player.current()?.dur || 0);
+ $("#now-fill").style.width = pct + "%";
+ $("#now-cur").textContent = fmt(cur);
+ $("#now-dur").textContent = fmt(dur || Player.current()?.dur || 0);
+};
+
+Player.onTrack = function (song) {
+ updatePlayerUI();
+ updateRows();
+ updateNow();
+ updateQueue();
+ document.title = song ? song.title + " — " + song.artist + " · Music" : "Music — Listen Now";
+};
+
+function updateRows() {
+ const rows = document.querySelectorAll(".song-row");
+ rows.forEach((row) => {
+ const s = Player.current();
+ const cur = s && row.dataset.id === s.id;
+ row.classList.toggle("is-cur", !!cur);
+ const idx = row.querySelector(".row-idx");
+ const rank = row.querySelector(".row-rank");
+ if (idx) idx.innerHTML = cur ? eqIcon() : esc(row.dataset.idxText || "");
+ if (rank) rank.innerHTML = cur ? eqIcon() : esc(row.dataset.idxText || "");
+ });
+}
+
+function updatePlayerUI() {
+ const cur = Player.current();
+ const playBtn = $("#pb-play");
+ const playBtnNow = $("#now-play");
+ const iconPlay = playBtn ? icon(Player.playing ? "pause" : "play", 20) : "";
+ const iconPlayNow = playBtnNow ? icon(Player.playing ? "pause" : "play", 44) : "";
+ if (playBtn) playBtn.innerHTML = iconPlay;
+ if (playBtnNow) playBtnNow.innerHTML = iconPlayNow;
+ if (cur) {
+ $("#pb-art-img").src = Art.song(cur);
+ $("#pb-title").textContent = cur.title;
+ $("#pb-sub").textContent = cur.artist;
+ $("#pb-art").classList.toggle("has-track", true);
+ const heart = $("#pb-heart");
+ if (heart) {
+ heart.classList.toggle("liked", Store.isLiked(cur.id));
+ heart.innerHTML = icon(Store.isLiked(cur.id) ? "heartFill" : "heart", 18);
+ heart.dataset.id = cur.id;
+ }
+ } else {
+ $("#pb-art").classList.remove("has-track");
+ $("#pb-title").textContent = "Nothing playing";
+ $("#pb-sub").textContent = "Pick a song to start";
+ $("#pb-art-img").src = Art.placeholder();
+ }
+ $("#pb-shuffle").classList.toggle("on", Player.shuffle);
+ $("#now-shuffle").classList.toggle("on", Player.shuffle);
+ const rep = Player.repeat;
+ $("#pb-repeat").classList.toggle("on", rep !== 0);
+ $("#pb-repeat").classList.toggle("one", rep === 2);
+ $("#pb-repeat").innerHTML = icon("repeat", 18) + (rep === 2 ? '1' : "");
+ $("#now-repeat").classList.toggle("on", rep !== 0);
+ $("#now-repeat").classList.toggle("one", rep === 2);
+ $("#now-repeat").innerHTML = icon("repeat", 20) + (rep === 2 ? '1' : "");
+ const muted = Player.audio.volume === 0;
+ $("#pb-mute").innerHTML = icon(muted ? "volumeX" : "volume", 18);
+ $("#now-mute").innerHTML = icon(muted ? "volumeX" : "volume", 20);
+ $("#pb-vol-fill").style.width = Player.volume * 100 + "%";
+ $("#now-vol-fill").style.width = Player.volume * 100 + "%";
+ $("#pb-fill").style.width = (Player.dur > 0 ? (Player.cur / Player.dur) * 100 : 0) + "%";
+ $("#pb-cur").textContent = fmt(Player.cur);
+ $("#pb-dur").textContent = fmt(Player.dur || (cur ? cur.dur : 0));
+ $("#now-fill").style.width = (Player.dur > 0 ? (Player.cur / Player.dur) * 100 : 0) + "%";
+ $("#now-cur").textContent = fmt(Player.cur);
+ $("#now-dur").textContent = fmt(Player.dur || (cur ? cur.dur : 0));
+}
+
+function navigate(hash) {
+ if (location.hash === hash) {
+ route();
+ } else {
+ location.hash = hash;
+ }
+ window.scrollTo && $("#main").scrollTo({ top: 0 });
+ $("#main").scrollTop = 0;
+}
+
+function route() {
+ closeMenu();
+ const raw = location.hash.replace(/^#\/?/, "");
+ const [path, query] = raw.split("?");
+ const parts = path.split("/").filter(Boolean);
+ const qs = query ? new URLSearchParams(query) : null;
+ const view = $("#view");
+ let html = "";
+
+ const root = parts[0] || "home";
+ const fresh = root !== "search" || prevRoute !== "search";
+ prevRoute = root;
+
+ if (root === "home" || root === "") html = viewHome();
+ else if (root === "browse") html = viewBrowse();
+ else if (root === "radio") html = viewRadio();
+ else if (root === "search") html = viewSearch(qs ? qs.get("q") || "" : "");
+ else if (root === "liked") html = viewLiked();
+ else if (root === "album" && parts[1]) html = viewAlbum(parts[1]);
+ else if (root === "artist" && parts[1]) html = viewArtist(parts[1]);
+ else if (root === "playlist" && parts[1]) html = viewPlaylist(parts[1]);
+ else if (root === "station" && parts[1]) html = viewStation(parts[1]);
+ else if (root === "genre" && parts[1]) html = viewGenre(decodeURIComponent(parts[1]));
+ else if (root === "library") html = viewLibrary(parts[1] || "playlists");
+ else html = viewHome();
+
+ view.innerHTML = html;
+ setActiveNav(root, parts[1]);
+ afterRender(fresh);
+}
+
+function setActiveNav(root, sub) {
+ const key = root === "library" ? sub : root;
+ document.querySelectorAll(".nav-item").forEach((el) => {
+ el.classList.toggle("active", el.dataset.nav === key);
+ });
+}
+
+function afterRender(fresh) {
+ const input = $("#search-input");
+ if (input && !input.dataset.bound) {
+ input.dataset.bound = "1";
+ input.addEventListener("input", () => {
+ clearTimeout(input._t);
+ input._t = setTimeout(() => {
+ navigate("#/search?q=" + encodeURIComponent(input.value.trim()));
+ }, 180);
+ });
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ const q = input.value.trim();
+ if (!q) return;
+ const res = D.search(q);
+ const s = res.songs[0];
+ if (s) {
+ const ctx = "search:" + q.toLowerCase();
+ ctxMap[ctx] = res.songs.map((x) => x.id);
+ Player.playContext(ctxMap[ctx], 0);
+ }
+ }
+ });
+ }
+ if (fresh && input) {
+ input.focus();
+ }
+}
+
+function renderSidebar() {
+ const pl = Store.playlists;
+ $("#sidebar").innerHTML = `
+
${icon("music", 22)}Music
+
+ Library
+
+ Playlists
+
+
+ `;
+}
+
+function renderPlayer() {
+ $("#player").innerHTML = `
+
+
+
})
+
${eqIcon()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+}
+
+function renderNow() {
+ $("#now").innerHTML = `
+
+
+
+
+
+
+
})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+}
+
+function updateNow() {
+ const cur = Player.current();
+ if (!cur) {
+ $("#now-title").textContent = "Nothing playing";
+ $("#now-artist").textContent = "";
+ $("#now-art").src = Art.placeholder();
+ $("#now-bg").src = Art.placeholder();
+ $("#now-art-inner").classList.remove("live");
+ return;
+ }
+ const art = Art.song(cur);
+ $("#now-art").src = art;
+ $("#now-bg").src = art;
+ $("#now-title").textContent = cur.title;
+ $("#now-artist").textContent = cur.artist + " — " + cur.album;
+ $("#now-heart").classList.toggle("liked", Store.isLiked(cur.id));
+ $("#now-heart").innerHTML = icon(Store.isLiked(cur.id) ? "heartFill" : "heart", 24);
+ $("#now-heart").dataset.id = cur.id;
+ $("#now-art-inner").classList.toggle("live", Player.playing);
+}
+
+function renderQueue() {
+ const q = Player.queue;
+ const cur = Player.current();
+ $("#qpanel").innerHTML = `
+
+
+
${cur ? esc(cur.title) : "Nothing playing"}
+
${cur ? esc(cur.artist) : "—"}
+
+
+
+
+
+
+
+ Up Next · ${q.length} ${q.length === 1 ? "song" : "songs"}
+
+ ${q.length ? q.map((s, i) => `
+
+
})
+
+
${i === Player.index ? eqIcon() : ""}${esc(s.title)}
+
${esc(s.artist)}
+
+
+
`).join("") : `
Queue is empty.
Play something to fill it.
`}
+
+ `;
+}
+
+function updateQueue() {
+ renderQueue();
+}
+
+function toggleQueue(open) {
+ const force = open != null ? open : !$("#qpanel").classList.contains("open");
+ $("#qpanel").classList.toggle("open", force);
+ if (force) renderQueue();
+}
+
+function toggleNow(open) {
+ const force = open != null ? open : !$("#now").classList.contains("open");
+ $("#now").classList.toggle("open", force);
+ if (force) {
+ Player.ensureGraph();
+ Player.resumeGraph();
+ updateNow();
+ requestAnimationFrame(vizLoop);
+ }
+}
+
+const PALETTES = [
+ ["#fa233b", "#fb5c74", "#2d0a12"],
+ ["#8338ec", "#ff4d6d", "#150a2e"],
+ ["#06d6a0", "#073b4c", "#0f2e26"],
+ ["#fca311", "#e5383b", "#241505"],
+ ["#3a86ff", "#8338ec", "#0a1b3d"],
+ ["#ffbe0b", "#ff006e", "#2e1208"]
+];
+
+function genrePalette(genre) {
+ const h = hashStr(genre);
+ return PALETTES[h % PALETTES.length];
+}
+
+function heroBg(palette) {
+ return `background-image: radial-gradient(120% 130% at 12% 8%, ${palette[0]}e6, transparent 55%), radial-gradient(120% 130% at 88% 95%, ${palette[2] || palette[0]}cc, transparent 55%), linear-gradient(150deg, #101014, ${palette[1]}44);`;
+}
+
+function songCard(s, ctx, index) {
+ return `
+
+
})
+
${esc(s.title)}
+
${esc(s.artist)}
+
`;
+}
+
+function albumCard(a) {
+ return `
+
+
})
+
${esc(a.title)}
+
${esc(D.artist(a.artistId).name)}
+
`;
+}
+
+function mixCard(m) {
+ return `
+
+
})
+
${esc(m.name)}
+
${esc(m.desc)}
+
`;
+}
+
+function stationCard(st) {
+ return `
+
+
})
+
${esc(st.name)}
+
${esc(st.desc)}
+
`;
+}
+
+function artistCard(a) {
+ return `
+
+
+
${esc(a.name)}
+
${esc(a.genre)}
+
`;
+}
+
+function plCard(p) {
+ const art = svgURI(artSVG([p.color, "#141417", p.color], hashStr(p.id), ["mesh", "orbital", "rings"][hashStr(p.id) % 3]));
+ return `
+
+

+
${esc(p.name)}
+
${p.items.length} ${p.items.length === 1 ? "song" : "songs"}
+
`;
+}
+
+function songRow(s, ctx, index, mode) {
+ const cur = Player.current();
+ const isCur = cur && cur.id === s.id;
+ const like = Store.isLiked(s.id);
+ if (mode === "num") {
+ return `
+
+
${isCur ? eqIcon() : index + 1}
+
+
${esc(s.title)}
+
${esc(s.artist)}
+
+
${esc(s.album)}
+
${fmt(s.dur)}
+
+
+
+
+
`;
+ }
+ return `
+
+
})
+
+
${esc(s.title)}
+
${esc(s.artist)}
+
+
${esc(s.album)}
+
${fmt(s.dur)}
+
+
+
+
+
`;
+}
+
+function chartRow(s, rank, ctx, index) {
+ const cur = Player.current();
+ const isCur = cur && cur.id === s.id;
+ const like = Store.isLiked(s.id);
+ return `
+
+
${isCur ? eqIcon() : rank}
+
})
+
+
${esc(s.title)}
+
${esc(s.artist)}
+
+
${fmtNum(s.plays)} plays
+
${fmt(s.dur)}
+
+
+
+
+
`;
+}
+
+function section(title, inner, seeAll) {
+ return `
+
+
+
${title}
+ ${seeAll ? `
See All` : ""}
+
+ ${inner}
+ `;
+}
+
+function viewHome() {
+ const feat = D.featuredAlbum();
+ const featAlbum = D.album(feat.id);
+ const ctxAlbum = "album:" + feat.id;
+ ctxMap[ctxAlbum] = D.albumSongs(feat.id).map((s) => s.id);
+
+ const recent = Store.recentSongs().slice(0, 10);
+ const recCtx = "recent";
+ ctxMap[recCtx] = recent.map((s) => s.id);
+
+ const charts = D.topCharts();
+ const chartCtx = "charts";
+ ctxMap[chartCtx] = charts.map((s) => s.id);
+
+ const newRel = D.newReleases();
+
+ return `
+
+
})
+
+
Featured Album
+
${esc(feat.title)}
+
${esc(featAlbum.artist)} · ${feat.year} · ${esc(feat.genre)}
+
${fmtNum(D.albumPlays(feat))} plays · ${fmtDurTotal(D.albumSongs(feat.id).reduce((t, s) => t + s.dur, 0))}
+
+
+
+
+
+
+ ${recent.length ? section("Recently Played", recent.map((s, i) => songCard(s, recCtx, i)).join("")) : ""}
+ ${section("Made For You", D.mixes.map(mixCard).join(""))}
+
+ Top Charts
+
+ ${charts.map((s, i) => chartRow(s, i + 1, chartCtx, i)).join("")}
+
+
+ ${section("New Releases", newRel.map(albumCard).join(""), "#/browse")}
+ `;
+}
+
+function viewBrowse() {
+ const genres = Array.from(new Set(D.songs.map((s) => s.genre))).sort();
+ const charts = D.topCharts();
+ const ctx = "charts";
+ ctxMap[ctx] = charts.map((s) => s.id);
+ return `
+ ${section("Genres", genres.map((g) => {
+ const p = genrePalette(g);
+ return `${esc(g)}
`;
+ }).join(""))}
+
+ Top Charts
+
+ ${charts.map((s, i) => chartRow(s, i + 1, ctx, i)).join("")}
+
+
+ ${section("All Albums", D.albums.slice().sort((a, b) => a.title.localeCompare(b.title)).map(albumCard).join(""))}
+ `;
+}
+
+function viewRadio() {
+ return `
+
+
${icon("radio", 22)}
+
+
Always On
+
Music Radio
+
Nonstop stations across every genre. Hit play and let it run.
+
+
+
+
+
+ ${section("Stations", D.stations.map(stationCard).join(""))}
+ `;
+}
+
+function viewSearch(q) {
+ const res = D.search(q);
+ const ctx = "search:" + q.toLowerCase();
+ ctxMap[ctx] = res.songs.map((s) => s.id);
+ const top = res.songs[0];
+ return `
+
+
+ ${icon("search", 20)}
+
+ ${q ? `` : ""}
+
+
+ ${!q ? `
+
+
Search for something
+
Try “Aurora”, “Neon”, “jazz”, or an album like “Velvet Hours”.
+
` : !res.songs.length && !res.albums.length && !res.artists.length ? `
+
+
No results for “${esc(q)}”
+
Check the spelling or try something else.
+
` : `
+ ${top ? `
+
+ Top Result
+
+
})
+
+
${esc(top.title)}
+
${esc(top.artist)} · ${esc(top.album)}
+
+
+
+ ` : ""}
+ ${res.songs.length ? `
+
+ Songs
+ ${res.songs.map((s, i) => songRow(s, ctx, i, "art")).join("")}
+ ` : ""}
+ ${res.albums.length ? section("Albums", res.albums.map(albumCard).join("")) : ""}
+ ${res.artists.length ? section("Artists", res.artists.map(artistCard).join("")) : ""}
+ `}
+ `;
+}
+
+function viewAlbum(id) {
+ const album = D.album(id);
+ if (!album) return "";
+ const artist = D.artist(album.artistId);
+ const songs = D.albumSongs(id).map((s) => D.song(s.id));
+ const ctx = "album:" + id;
+ ctxMap[ctx] = songs.map((s) => s.id);
+ const totalDur = songs.reduce((t, s) => t + s.dur, 0);
+ return `
+
+
})
+
+
Album
+
${esc(album.title)}
+
+
${fmtNum(D.albumPlays(album))} plays · ${fmtDurTotal(totalDur)}
+
+
+
+
+
+
+ ${songs.map((s, i) => songRow(s, ctx, i, "num")).join("")}
+ ${section("More by " + artist.name, D.artistAlbums(artist.id).filter((a) => a.id !== id).map(albumCard).join(""))}
+ `;
+}
+
+function viewArtist(id) {
+ const artist = D.artist(id);
+ if (!artist) return "";
+ const songs = D.artistSongs(id).slice(0, 8).map((s) => D.song(s.id));
+ const ctx = "artist:" + id;
+ ctxMap[ctx] = D.artistSongs(id).map((s) => s.id);
+ return `
+
+
})
+
+
Artist
+
${esc(artist.name)}
+
${esc(artist.genre)}
+
${fmtNum(artist.followers)} monthly listeners
+
+
+
+
+
+
+ ${esc(artist.bio)}
+ ${songs.map((s, i) => songRow(s, ctx, i, "art")).join("")}
+ ${section("Albums", D.artistAlbums(id).map(albumCard).join(""))}
+ `;
+}
+
+function viewPlaylist(id) {
+ const isMix = id.startsWith("mix-");
+ if (isMix) {
+ const mix = D.mixes.find((m) => m.id === id);
+ if (!mix) return "";
+ const songs = D.mixSongs(mix);
+ const ctx = "mix:" + id;
+ ctxMap[ctx] = songs.map((s) => s.id);
+ return `
+
+
})
+
+
Made For You
+
${esc(mix.name)}
+
${esc(mix.desc)}
+
${songs.length} songs · ${fmtDurTotal(songs.reduce((t, s) => t + s.dur, 0))}
+
+
+
+
+
+
+ ${songs.map((s, i) => songRow(s, ctx, i, "art")).join("")}
+ `;
+ }
+ const pl = Store.playlists.find((p) => p.id === id);
+ if (!pl) return "";
+ const songs = Store.playlistSongs(id);
+ const ctx = "playlist:" + id;
+ ctxMap[ctx] = songs.map((s) => s.id);
+ const art = svgURI(artSVG([pl.color, "#141417", pl.color], hashStr(pl.id), ["mesh", "orbital", "rings"][hashStr(pl.id) % 3]));
+ return `
+
+

+
+
Playlist
+
${esc(pl.name)}
+
${songs.length} ${songs.length === 1 ? "song" : "songs"} · created ${new Date(pl.createdAt).toLocaleDateString(undefined, { month: "long", year: "numeric" })}
+
+
+
+
+
+
+
+
+ ${songs.length ? `
+ ${songs.map((s, i) => {
+ const row = songRow(s, ctx, i, "art");
+ return row;
+ }).join("")}
` : `
+
+
${icon("music", 30)}
+
This playlist is empty
+
Tap ${icon("dots", 14)} on any song and choose “Add to Playlist”.
+
`}
+ `;
+}
+
+function viewStation(id) {
+ const st = D.stations.find((s) => s.id === id);
+ if (!st) return "";
+ const songs = D.stationSongs(st).sort((a, b) => b.plays - a.plays);
+ const ctx = "station:" + id;
+ ctxMap[ctx] = songs.map((s) => s.id);
+ return `
+
+
})
+
+
Radio Station
+
${esc(st.name)}
+
${esc(st.desc)}
+
${songs.length} tracks in rotation
+
+
+
+
+
+
+ ${songs.slice(0, 25).map((s, i) => songRow(s, ctx, i, "art")).join("")}
+ `;
+}
+
+function viewGenre(genre) {
+ const albums = D.genreAlbums(genre);
+ const songs = D.genreOf(genre).sort((a, b) => b.plays - a.plays);
+ const ctx = "genre:" + genre;
+ ctxMap[ctx] = songs.map((s) => s.id);
+ const p = genrePalette(genre);
+ return `
+
+
${icon("sparkle", 24)}
+
+
Genre
+
${esc(genre)}
+
${songs.length} songs · ${fmtNum(songs.reduce((t, s) => t + s.plays, 0))} total plays
+
+
+
+
+
+
+ ${songs.slice(0, 15).map((s, i) => songRow(s, ctx, i, "art")).join("")}
+ ${section("Albums", albums.map(albumCard).join(""))}
+ `;
+}
+
+function viewLiked() {
+ const songs = Store.likedSongs().sort((a, b) => a.title.localeCompare(b.title));
+ const ctx = "liked";
+ ctxMap[ctx] = songs.map((s) => s.id);
+ return `
+
+
${icon("heartFill", 90)}
+
+
Playlist
+
Liked Songs
+
Every song you've hearted, in one place.
+
${songs.length} ${songs.length === 1 ? "song" : "songs"}
+
+
+
+
+
+
+ ${songs.length ? `${songs.map((s, i) => songRow(s, ctx, i, "art")).join("")}
` : `
+
+
${icon("heart", 30)}
+
No liked songs yet
+
Tap the heart on any song to save it here.
+
`}
+ `;
+}
+
+function viewLibrary(tab) {
+ const tabs = [
+ ["playlists", "Playlists"],
+ ["artists", "Artists"],
+ ["albums", "Albums"],
+ ["songs", "Songs"]
+ ];
+ let body = "";
+ if (tab === "playlists") {
+ const pls = Store.playlists;
+ body = `
+ ${pls.length ? `${pls.map(plCard).join("")}
` : `
+
+
${icon("queue", 30)}
+
No playlists yet
+
Create one and start collecting songs.
+
+
`}`;
+ } else if (tab === "artists") {
+ body = `${D.artists.map(artistCard).join("")}
`;
+ } else if (tab === "albums") {
+ body = `${D.albums.slice().sort((a, b) => a.title.localeCompare(b.title)).map(albumCard).join("")}
`;
+ } else {
+ const songs = D.songs.slice().sort((a, b) => a.title.localeCompare(b.title)).map((s) => D.song(s.id));
+ const ctx = "libsongs";
+ ctxMap[ctx] = songs.map((s) => s.id);
+ body = `${songs.map((s, i) => songRow(s, ctx, i, "art")).join("")}
`;
+ }
+ return `
+ Library
+
+ ${tabs.map(([t, label]) => ``).join("")}
+
+ ${body}
+ `;
+}
+
+function showToast(msg) {
+ const t = $("#toast");
+ t.innerHTML = msg;
+ t.classList.add("show");
+ clearTimeout(showToast._t);
+ showToast._t = setTimeout(() => t.classList.remove("show"), 2200);
+}
+
+function openModal(html) {
+ $("#modal-root").innerHTML = `
+ `;
+ const input = $(".modal input");
+ if (input) input.focus();
+}
+
+function closeModal() {
+ $("#modal-root").innerHTML = "";
+}
+
+function playlistForm(pl) {
+ const colors = ["#fa233b", "#fb5c74", "#8338ec", "#3a86ff", "#06d6a0", "#fca311", "#ff006e", "#e09f3e"];
+ return `
+ ${pl ? "Rename Playlist" : "New Playlist"}
+
+
+ ${colors.map((c, i) => ``).join("")}
+
+
+
+
+
`;
+}
+
+function openMenu(x, y, items) {
+ closeMenu();
+ const root = $("#menu-root");
+ root.innerHTML = ``;
+ const menu = $(".menu", root);
+ const mw = menu.offsetWidth, mh = menu.offsetHeight;
+ const px = Math.min(x, window.innerWidth - mw - 12);
+ const py = Math.min(y, window.innerHeight - mh - 110);
+ menu.style.left = px + "px";
+ menu.style.top = py + "px";
+ menu._items = items;
+ menu._menuEl = menu;
+ root.style.pointerEvents = "auto";
+}
+
+function closeMenu() {
+ $("#menu-root").innerHTML = "";
+}
+
+function bindBar(bar, cb) {
+ bar.addEventListener("pointerdown", (e) => {
+ if (e.button && e.button !== 0) return;
+ e.preventDefault();
+ const move = (ev) => {
+ const r = bar.getBoundingClientRect();
+ cb(Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width)));
+ };
+ move(e);
+ const onMove = (ev) => move(ev);
+ const onUp = () => {
+ window.removeEventListener("pointermove", onMove);
+ window.removeEventListener("pointerup", onUp);
+ };
+ window.addEventListener("pointermove", onMove);
+ window.addEventListener("pointerup", onUp);
+ });
+}
+
+let vizRaf = 0;
+function vizLoop() {
+ const canvas = $("#now-viz");
+ if (!canvas || !$("#now").classList.contains("open")) return;
+ vizRaf = requestAnimationFrame(vizLoop);
+ const wrap = canvas.parentElement;
+ const w = wrap.clientWidth, h = wrap.clientHeight;
+ if (!w || !h) return;
+ if (canvas.width !== w) canvas.width = w;
+ if (canvas.height !== h) canvas.height = h;
+ const g = canvas.getContext("2d");
+ g.clearRect(0, 0, w, h);
+ let data = null;
+ let sum = 0;
+ if (Player.analyser) {
+ Player.analyser.getByteFrequencyData(Player.freq);
+ data = Player.freq;
+ for (let i = 0; i < data.length; i++) sum += data[i];
+ }
+ const t = performance.now() / 1000;
+ const n = 48;
+ const bw = w / n;
+ for (let i = 0; i < n; i++) {
+ let v = 0.06;
+ if (data && sum > 0) {
+ v = data[Math.floor((i / n) * data.length * 0.8)] / 255;
+ } else if (Player.playing) {
+ v = Math.max(0.08, 0.45 + 0.4 * Math.sin(t * 3 + i * 0.55) * Math.sin(t * 1.7 + i * 0.23));
+ }
+ const hh = Math.max(3, v * h * 0.9);
+ const x = i * bw + bw * 0.25;
+ const grad = g.createLinearGradient(0, h, 0, 0);
+ grad.addColorStop(0, "rgba(250,35,59,0.05)");
+ grad.addColorStop(1, "rgba(251,92,116,0.9)");
+ g.fillStyle = grad;
+ g.beginPath();
+ g.roundRect(x, h - hh, bw * 0.5, hh, bw * 0.25);
+ g.fill();
+ }
+}
+
+function initEvents() {
+ document.addEventListener("click", (e) => {
+ const menuBtn = e.target.closest("[data-midx]");
+ if (menuBtn) {
+ const menu = $(".menu", $("#menu-root"));
+ if (menu && menu._items) {
+ menu._items[+menuBtn.dataset.midx].run(e);
+ }
+ closeMenu();
+ return;
+ }
+ const nav = e.target.closest("[data-go]");
+ if (nav) {
+ e.preventDefault();
+ navigate(nav.dataset.go);
+ return;
+ }
+ const actionEl = e.target.closest("[data-action]");
+ if (!actionEl) {
+ if (!e.target.closest(".menu")) closeMenu();
+ return;
+ }
+ const act = actionEl.dataset.action;
+ const id = actionEl.dataset.id;
+ const ctx = actionEl.dataset.ctx;
+ const index = actionEl.dataset.index;
+
+ if (act === "play") {
+ if (ctxMap[ctx]) Player.playContext(ctxMap[ctx], +index || 0);
+ } else if (act === "play-ctx") {
+ if (ctxMap[ctx]) Player.playContext(ctxMap[ctx], 0);
+ } else if (act === "shuffle-ctx") {
+ if (ctxMap[ctx]) {
+ const i = Math.floor(Math.random() * ctxMap[ctx].length);
+ Player.playContext(ctxMap[ctx], i);
+ }
+ } else if (act === "play-station") {
+ const st = D.stations.find((s) => s.id === id);
+ if (!st) return;
+ const songs = D.stationSongs(st).map((s) => s.id);
+ const i = Math.floor(Math.random() * songs.length);
+ Player.playContext(songs, i);
+ } else if (act === "play-pl") {
+ const pl = Store.playlists.find((p) => p.id === id);
+ if (pl && pl.items.length) {
+ Player.playContext(pl.items, 0);
+ }
+ } else if (act === "toggle") {
+ Player.togglePlay();
+ } else if (act === "next") {
+ Player.next();
+ } else if (act === "prev") {
+ Player.prev();
+ } else if (act === "shuffle") {
+ Player.toggleShuffle();
+ updatePlayerUI();
+ renderQueue();
+ } else if (act === "repeat") {
+ Player.cycleRepeat();
+ updatePlayerUI();
+ renderQueue();
+ } else if (act === "mute") {
+ Player.toggleMute();
+ updatePlayerUI();
+ } else if (act === "like") {
+ if (!id) return;
+ const liked = Store.toggleLike(id);
+ Store.save();
+ updatePlayerUI();
+ updateNow();
+ route();
+ showToast(liked ? "Added to Liked Songs" : "Removed from Liked Songs");
+ } else if (act === "more") {
+ const s = D.song(id);
+ if (!s) return;
+ const inUserPlaylist = /^#\/playlist\/pl/.test(location.hash);
+ const menuItems = [
+ {
+ label: "Play Next",
+ icon: "play",
+ run: () => {
+ const q = Player.queue;
+ if (!q.length) {
+ Player.playContext([s], 0);
+ } else {
+ q.splice(Player.index + 1, 0, s);
+ renderQueue();
+ showToast("Added to Up Next");
+ }
+ }
+ },
+ {
+ label: "Add to Playlist…",
+ icon: "plus",
+ run: () => {
+ const pls = Store.playlists;
+ const items = pls.map((p) => ({
+ label: p.name + (p.items.includes(s.id) ? " ✓" : ""),
+ icon: "queue",
+ run: () => {
+ if (Store.addToPlaylist(p.id, s.id)) showToast('Added to "' + p.name + '"');
+ else showToast('Already in "' + p.name + '"');
+ }
+ }));
+ items.push({ sep: true }, {
+ label: "New Playlist…",
+ icon: "plus",
+ run: () => openModal(playlistForm(null))
+ });
+ const mx = Math.min(e.clientX, window.innerWidth - 220);
+ const my = Math.min(e.clientY, window.innerHeight - 260);
+ openMenu(mx, my, items);
+ }
+ },
+ { sep: true },
+ {
+ label: Store.isLiked(s.id) ? "Remove from Liked" : "Like",
+ icon: Store.isLiked(s.id) ? "heart" : "heart",
+ run: () => {
+ Store.toggleLike(s.id);
+ Store.save();
+ route();
+ }
+ },
+ {
+ label: "Go to Album",
+ icon: "grid",
+ run: () => navigate("#/album/" + s.albumId)
+ },
+ {
+ label: "Go to Artist",
+ icon: "music",
+ run: () => navigate("#/artist/" + s.artistId)
+ }
+ ];
+ if (inUserPlaylist) {
+ menuItems.splice(1, 0, { sep: true }, {
+ label: "Remove from Playlist",
+ icon: "trash",
+ danger: true,
+ run: () => {
+ const plId = location.hash.replace(/^#\/playlist\//, "");
+ Store.removeFromPlaylist(plId, s.id);
+ route();
+ showToast("Removed from playlist");
+ }
+ });
+ }
+ openMenu(e.clientX, e.clientY, menuItems);
+ } else if (act === "open-now") {
+ toggleNow(true);
+ } else if (act === "close-now") {
+ toggleNow(false);
+ } else if (act === "toggle-queue") {
+ toggleQueue();
+ } else if (act === "close-queue") {
+ toggleQueue(false);
+ } else if (act === "queue-jump") {
+ Player.index = +index;
+ Player.loadCurrent();
+ } else if (act === "queue-remove") {
+ Player.removeAt(+index);
+ renderQueue();
+ updatePlayerUI();
+ } else if (act === "open-album") {
+ navigate("#/album/" + id);
+ } else if (act === "open-artist") {
+ navigate("#/artist/" + id);
+ } else if (act === "open-playlist") {
+ navigate("#/playlist/" + id);
+ } else if (act === "open-station") {
+ navigate("#/station/" + id);
+ } else if (act === "open-genre") {
+ navigate("#/genre/" + encodeURIComponent(actionEl.dataset.genre));
+ } else if (act === "open-library") {
+ navigate("#/library/" + actionEl.dataset.tab);
+ } else if (act === "clear-search") {
+ navigate("#/search");
+ } else if (act === "create-playlist") {
+ openModal(playlistForm(null));
+ } else if (act === "rename-playlist") {
+ const pl = Store.playlists.find((p) => p.id === id);
+ if (pl) openModal(playlistForm(pl));
+ } else if (act === "clear-playlist") {
+ Store.clearPlaylist(id);
+ route();
+ showToast("Playlist cleared");
+ } else if (act === "modal-close") {
+ closeModal();
+ } else if (act === "modal-create-save") {
+ const name = $(".modal-input").value.trim();
+ const color = $(".swatch.sel").dataset.color;
+ const pl = Store.createPlaylist(name, color);
+ closeModal();
+ renderSidebar();
+ showToast('Playlist "' + pl.name + '" created');
+ navigate("#/playlist/" + pl.id);
+ } else if (act === "modal-rename-save") {
+ const name = $(".modal-input").value.trim();
+ Store.renamePlaylist(id, name);
+ closeModal();
+ renderSidebar();
+ route();
+ showToast("Playlist renamed");
+ }
+ });
+
+ document.addEventListener("click", (e) => {
+ const sw = e.target.closest(".swatch");
+ if (sw) {
+ $(".swatch.sel") && $(".swatch.sel").classList.remove("sel");
+ sw.classList.add("sel");
+ }
+ });
+
+ const modalRoot = $("#modal-root");
+ modalRoot.addEventListener("click", (e) => {
+ if (e.target.classList.contains("modal-backdrop")) closeModal();
+ });
+
+ bindBar($("#pb-bar"), (r) => Player.seek(r * Player.dur));
+ bindBar($("#pb-vol"), (r) => Player.setVolume(r));
+ bindBar($("#now-bar"), (r) => Player.seek(r * Player.dur));
+ bindBar($("#now-vol"), (r) => Player.setVolume(r));
+
+ document.addEventListener("keydown", (e) => {
+ const typing = /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName);
+ if (e.key === "Escape") {
+ closeMenu();
+ closeModal();
+ toggleQueue(false);
+ toggleNow(false);
+ return;
+ }
+ if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) {
+ e.preventDefault();
+ navigate("#/search");
+ return;
+ }
+ if (typing) return;
+ if (e.code === "Space") {
+ e.preventDefault();
+ Player.togglePlay();
+ } else if (e.key === "ArrowRight") {
+ Player.seek(Player.cur + 10);
+ } else if (e.key === "ArrowLeft") {
+ Player.seek(Player.cur - 10);
+ } else if (e.key === "ArrowUp") {
+ Player.setVolume(Player.volume + 0.05);
+ updatePlayerUI();
+ } else if (e.key === "ArrowDown") {
+ Player.setVolume(Player.volume - 0.05);
+ updatePlayerUI();
+ } else if (e.key === "n" || e.key === "N") {
+ Player.next();
+ } else if (e.key === "p" || e.key === "P") {
+ Player.prev();
+ } else if (e.key === "l" || e.key === "L") {
+ const cur = Player.current();
+ if (cur) {
+ Store.toggleLike(cur.id);
+ Store.save();
+ updatePlayerUI();
+ updateNow();
+ updateRows();
+ showToast(Store.isLiked(cur.id) ? "Added to Liked Songs" : "Removed from Liked Songs");
+ }
+ } else if (e.key === "m" || e.key === "M") {
+ Player.toggleMute();
+ updatePlayerUI();
+ } else if (e.key === "q" || e.key === "Q") {
+ toggleQueue();
+ }
+ });
+
+ window.addEventListener("hashchange", route);
+}
+
+function init() {
+ renderSidebar();
+ renderPlayer();
+ renderNow();
+ initEvents();
+ updatePlayerUI();
+ route();
+}
+
+init();