Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
1306 lines
52 KiB
JavaScript
1306 lines
52 KiB
JavaScript
"use strict";
|
|
|
|
const ICONS = {
|
|
play: `<path d="M8 5.14v13.72c0 .86.94 1.4 1.68.96l11.02-6.86a1.12 1.12 0 0 0 0-1.92L9.68 4.18A1.12 1.12 0 0 0 8 5.14z"/>`,
|
|
pause: `<path d="M6 5h4v14H6zM14 5h4v14h-4z"/>`,
|
|
next: `<path d="M5 5.5v13c0 .9 1 1.4 1.8.9l10-6.5c.7-.4.7-1.4 0-1.8l-10-6.5C6 4.1 5 4.6 5 5.5z"/><path d="M18 4h2.5v16H18z"/>`,
|
|
prev: `<path d="M19 5.5v13c0 .9-1 1.4-1.8.9l-10-6.5c-.7-.4-.7-1.4 0-1.8l10-6.5c.8-.5 1.8 0 1.8.9z"/><path d="M6 4H3.5v16H6z"/>`,
|
|
heart: `<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>`,
|
|
heartFill: `<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>`,
|
|
home: `<path d="M3 11.5L12 4l9 7.5"/><path d="M5.5 9.5V20h13V9.5"/>`,
|
|
search: `<circle cx="11" cy="11" r="7"/><path d="M21 21l-5.5-5.5"/>`,
|
|
library: `<path d="M4 7h16M4 12h16M4 17h10"/>`,
|
|
grid: `<rect x="4" y="4" width="6" height="6" rx="1.5"/><rect x="14" y="4" width="6" height="6" rx="1.5"/><rect x="4" y="14" width="6" height="6" rx="1.5"/><rect x="14" y="14" width="6" height="6" rx="1.5"/>`,
|
|
radio: `<circle cx="12" cy="12" r="2"/><path d="M12 17a5 5 0 0 1-5-5"/><path d="M12 19.5a7.5 7.5 0 0 1-7.5-7.5"/><path d="M7.2 7.2a7 7 0 0 1 9.6 0"/><path d="M4.6 4.6a11.3 11.3 0 0 1 14.8 0"/><path d="M12 14v6"/>`,
|
|
shuffle: `<path d="M16 3h5v5"/><path d="M4 20L21 3"/><path d="M21 16v5h-5"/><path d="M15 15l6 6"/><path d="M4 4l5 5"/>`,
|
|
repeat: `<path d="M17 2l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 22l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>`,
|
|
volume: `<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>`,
|
|
volumeX: `<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M22 9l-6 6"/><path d="M16 9l6 6"/>`,
|
|
queue: `<path d="M8 6h13M8 12h13M8 18h13"/><path d="M3 6h.01M3 12h.01M3 18h.01"/>`,
|
|
plus: `<path d="M12 5v14M5 12h14"/>`,
|
|
x: `<path d="M18 6L6 18M6 6l12 12"/>`,
|
|
trash: `<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/>`,
|
|
pencil: `<path d="M17 3a2.83 2.83 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>`,
|
|
dots: `<circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/>`,
|
|
music: `<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>`,
|
|
clock: `<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>`,
|
|
sparkle: `<path d="M12 2l2.4 7.6L22 12l-7.6 2.4L12 22l-2.4-7.6L2 12l7.6-2.4L12 2z"/>`,
|
|
check: `<path d="M20 6L9 17l-5-5"/>`,
|
|
chevron: `<path d="M6 9l6 6 6-6"/>`
|
|
};
|
|
|
|
function icon(name, size) {
|
|
const s = size || 20;
|
|
const solid = ["play", "pause", "next", "prev", "heartFill", "grid", "sparkle"];
|
|
const svg = `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="${solid.includes(name) ? "currentColor" : "none"}" stroke="currentColor" stroke-width="${solid.includes(name) ? 0 : 2}" stroke-linecap="round" stroke-linejoin="round">${ICONS[name]}</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, ">").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 `<span class="eq" title="Playing"><span></span><span></span><span></span></span>`;
|
|
}
|
|
|
|
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 ? '<span class="rep-one">1</span>' : "");
|
|
$("#now-repeat").classList.toggle("on", rep !== 0);
|
|
$("#now-repeat").classList.toggle("one", rep === 2);
|
|
$("#now-repeat").innerHTML = icon("repeat", 20) + (rep === 2 ? '<span class="rep-one">1</span>' : "");
|
|
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 = `
|
|
<div class="logo"><span class="logo-mark">${icon("music", 22)}</span>Music</div>
|
|
<nav class="nav">
|
|
<a class="nav-item" data-nav="home" data-go="#/">${icon("home", 19)}<span>Home</span></a>
|
|
<a class="nav-item" data-nav="browse" data-go="#/browse">${icon("grid", 19)}<span>Browse</span></a>
|
|
<a class="nav-item" data-nav="radio" data-go="#/radio">${icon("radio", 19)}<span>Radio</span></a>
|
|
<a class="nav-item" data-nav="search" data-go="#/search">${icon("search", 19)}<span>Search</span></a>
|
|
</nav>
|
|
<div class="section-label">Library</div>
|
|
<nav class="nav">
|
|
<a class="nav-item" data-nav="liked" data-go="#/liked">${icon("heart", 19)}<span>Liked Songs</span></a>
|
|
<a class="nav-item" data-nav="playlists" data-go="#/library/playlists">${icon("library", 19)}<span>Playlists</span></a>
|
|
<a class="nav-item" data-nav="artists" data-go="#/library/artists">${icon("music", 19)}<span>Artists</span></a>
|
|
<a class="nav-item" data-nav="albums" data-go="#/library/albums">${icon("grid", 19)}<span>Albums</span></a>
|
|
<a class="nav-item" data-nav="songs" data-go="#/library/songs">${icon("queue", 19)}<span>Songs</span></a>
|
|
</nav>
|
|
<div class="section-label row-between"><span>Playlists</span><button class="icon-btn" data-action="create-playlist" title="New Playlist">${icon("plus", 16)}</button></div>
|
|
<nav class="nav pl-list">
|
|
${pl.length ? pl.map((p) => `
|
|
<a class="nav-item pl-item" data-nav="playlist" data-go="#/playlist/${p.id}">
|
|
<span class="pl-dot" style="background:${esc(p.color)}"></span><span class="pl-name">${esc(p.name)}</span>
|
|
</a>`).join("") : `<div class="pl-empty">No playlists yet</div>`}
|
|
</nav>
|
|
<div class="side-footer">${D.songs.length} songs · ${fmtDurTotal(D.totalDur)}</div>
|
|
`;
|
|
}
|
|
|
|
function renderPlayer() {
|
|
$("#player").innerHTML = `
|
|
<div class="pb-left">
|
|
<div class="pb-art" data-action="open-now">
|
|
<img id="pb-art-img" src="${Art.placeholder()}" alt="">
|
|
<div class="pb-eq">${eqIcon()}</div>
|
|
</div>
|
|
<div class="pb-meta" data-action="open-now">
|
|
<div class="pb-title" id="pb-title">Nothing playing</div>
|
|
<div class="pb-sub" id="pb-sub">Pick a song to start</div>
|
|
</div>
|
|
<button class="icon-btn pb-heart" id="pb-heart" data-action="like" data-id="">${icon("heart", 18)}</button>
|
|
</div>
|
|
<div class="pb-center">
|
|
<div class="pb-controls">
|
|
<button class="icon-btn ${Player.shuffle ? "on" : ""}" id="pb-shuffle" data-action="shuffle" title="Shuffle">${icon("shuffle", 18)}</button>
|
|
<button class="icon-btn ctrl-big" data-action="prev" title="Previous">${icon("prev", 22)}</button>
|
|
<button class="play-btn" id="pb-play" data-action="toggle">${icon("play", 20)}</button>
|
|
<button class="icon-btn ctrl-big" data-action="next" title="Next">${icon("next", 22)}</button>
|
|
<button class="icon-btn" id="pb-repeat" data-action="repeat" title="Repeat">${icon("repeat", 18)}</button>
|
|
</div>
|
|
<div class="pb-progress">
|
|
<span class="t" id="pb-cur">0:00</span>
|
|
<div class="bar" id="pb-bar"><div class="bar-fill" id="pb-fill"></div><div class="bar-thumb"></div></div>
|
|
<span class="t" id="pb-dur">0:00</span>
|
|
</div>
|
|
</div>
|
|
<div class="pb-right">
|
|
<button class="icon-btn qbtn" data-action="toggle-queue" title="Up Next">${icon("queue", 20)}</button>
|
|
<button class="icon-btn" id="pb-mute" data-action="mute" title="Mute">${icon("volume", 18)}</button>
|
|
<div class="vol-wrap">
|
|
<div class="bar vbar" id="pb-vol"><div class="bar-fill" id="pb-vol-fill"></div><div class="bar-thumb"></div></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderNow() {
|
|
$("#now").innerHTML = `
|
|
<div class="now-bg"><img id="now-bg" alt=""></div>
|
|
<button class="now-close" data-action="close-now">${icon("chevron", 22)}</button>
|
|
<div class="now-inner">
|
|
<div class="now-left">
|
|
<div class="now-art-wrap">
|
|
<div class="now-art-inner" id="now-art-inner">
|
|
<img id="now-art" src="${Art.placeholder()}" alt="">
|
|
<canvas id="now-viz"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="now-right">
|
|
<div class="now-meta">
|
|
<div class="now-title" id="now-title">Nothing playing</div>
|
|
<div class="now-artist" id="now-artist"></div>
|
|
</div>
|
|
<div class="now-controls">
|
|
<button class="icon-btn ${Store.isLiked("") ? "liked" : ""}" id="now-heart" data-action="like" data-id="">${icon("heart", 24)}</button>
|
|
<button class="icon-btn ${Player.shuffle ? "on" : ""}" id="now-shuffle" data-action="shuffle">${icon("shuffle", 20)}</button>
|
|
<button class="icon-btn ctrl-big" data-action="prev">${icon("prev", 26)}</button>
|
|
<button class="play-btn big" id="now-play" data-action="toggle">${icon("play", 44)}</button>
|
|
<button class="icon-btn ctrl-big" data-action="next">${icon("next", 26)}</button>
|
|
<button class="icon-btn" id="now-repeat" data-action="repeat">${icon("repeat", 20)}</button>
|
|
<button class="icon-btn qbtn" data-action="toggle-queue">${icon("queue", 22)}</button>
|
|
</div>
|
|
<div class="now-seek">
|
|
<span class="t" id="now-cur">0:00</span>
|
|
<div class="bar big" id="now-bar"><div class="bar-fill" id="now-fill"></div><div class="bar-thumb"></div></div>
|
|
<span class="t" id="now-dur">0:00</span>
|
|
</div>
|
|
<div class="now-vol-row">
|
|
<button class="icon-btn" id="now-mute" data-action="mute">${icon("volume", 20)}</button>
|
|
<div class="bar vbar wide" id="now-vol"><div class="bar-fill" id="now-vol-fill"></div><div class="bar-thumb"></div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 = `
|
|
<div class="qp-head">
|
|
<div>
|
|
<div class="qp-title">${cur ? esc(cur.title) : "Nothing playing"}</div>
|
|
<div class="qp-sub">${cur ? esc(cur.artist) : "—"}</div>
|
|
</div>
|
|
<div class="qp-head-actions">
|
|
<button class="icon-btn ${Player.shuffle ? "on" : ""}" data-action="shuffle">${icon("shuffle", 18)}</button>
|
|
<button class="icon-btn ${Player.repeat ? "on" : ""}" data-action="repeat">${icon("repeat", 18)}</button>
|
|
<button class="icon-btn" data-action="close-queue">${icon("x", 20)}</button>
|
|
</div>
|
|
</div>
|
|
<div class="qp-upnext">Up Next · ${q.length} ${q.length === 1 ? "song" : "songs"}</div>
|
|
<div class="qp-list">
|
|
${q.length ? q.map((s, i) => `
|
|
<div class="qp-row ${i === Player.index ? "is-cur" : ""}" data-action="queue-jump" data-index="${i}">
|
|
<img src="${Art.song(s)}" alt="">
|
|
<div class="qp-main">
|
|
<div class="qp-name">${i === Player.index ? eqIcon() : ""}${esc(s.title)}</div>
|
|
<div class="qp-artist">${esc(s.artist)}</div>
|
|
</div>
|
|
<button class="icon-btn" data-action="queue-remove" data-index="${i}" title="Remove">${icon("x", 16)}</button>
|
|
</div>`).join("") : `<div class="qp-empty">Queue is empty.<br>Play something to fill it.</div>`}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="card" data-action="play" data-ctx="${ctx}" data-index="${index}">
|
|
<div class="card-art"><img src="${Art.song(s)}" alt=""><button class="card-play">${icon("play", 18)}</button></div>
|
|
<div class="card-title">${esc(s.title)}</div>
|
|
<div class="card-sub">${esc(s.artist)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function albumCard(a) {
|
|
return `
|
|
<div class="card" data-action="open-album" data-id="${a.id}">
|
|
<div class="card-art"><img src="${Art.album(a)}" alt=""><button class="card-play" data-action="play-ctx" data-ctx="album:${a.id}">${icon("play", 18)}</button></div>
|
|
<div class="card-title">${esc(a.title)}</div>
|
|
<div class="card-sub">${esc(D.artist(a.artistId).name)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function mixCard(m) {
|
|
return `
|
|
<div class="card" data-action="open-playlist" data-id="${m.id}">
|
|
<div class="card-art"><img src="${Art.mix(m)}" alt=""><button class="card-play" data-action="play-ctx" data-ctx="mix:${m.id}">${icon("play", 18)}</button></div>
|
|
<div class="card-title">${esc(m.name)}</div>
|
|
<div class="card-sub">${esc(m.desc)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function stationCard(st) {
|
|
return `
|
|
<div class="card station-card" data-action="open-station" data-id="${st.id}">
|
|
<div class="card-art"><img src="${Art.station(st)}" alt=""><button class="card-play" data-action="play-station" data-id="${st.id}">${icon("play", 18)}</button></div>
|
|
<div class="card-title">${esc(st.name)}</div>
|
|
<div class="card-sub">${esc(st.desc)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function artistCard(a) {
|
|
return `
|
|
<div class="card artist-card" data-action="open-artist" data-id="${a.id}">
|
|
<div class="card-art round"><img src="${Art.artist(a)}" alt=""></div>
|
|
<div class="card-title center">${esc(a.name)}</div>
|
|
<div class="card-sub center">${esc(a.genre)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function plCard(p) {
|
|
const art = svgURI(artSVG([p.color, "#141417", p.color], hashStr(p.id), ["mesh", "orbital", "rings"][hashStr(p.id) % 3]));
|
|
return `
|
|
<div class="card" data-action="open-playlist" data-id="${p.id}">
|
|
<div class="card-art"><img src="${art}" alt=""><button class="card-play" data-action="play-pl" data-id="${p.id}">${icon("play", 18)}</button></div>
|
|
<div class="card-title">${esc(p.name)}</div>
|
|
<div class="card-sub">${p.items.length} ${p.items.length === 1 ? "song" : "songs"}</div>
|
|
</div>`;
|
|
}
|
|
|
|
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 `
|
|
<div class="song-row num" data-action="play" data-ctx="${ctx}" data-index="${index}" data-id="${s.id}" data-idx-text="${index + 1}">
|
|
<span class="row-idx">${isCur ? eqIcon() : index + 1}</span>
|
|
<div class="row-main">
|
|
<div class="row-title">${esc(s.title)}</div>
|
|
<div class="row-sub">${esc(s.artist)}</div>
|
|
</div>
|
|
<div class="row-album">${esc(s.album)}</div>
|
|
<div class="row-time">${fmt(s.dur)}</div>
|
|
<div class="row-actions">
|
|
<button class="icon-btn ${like ? "liked" : ""}" data-action="like" data-id="${s.id}">${icon(like ? "heartFill" : "heart", 16)}</button>
|
|
<button class="icon-btn" data-action="more" data-id="${s.id}">${icon("dots", 18)}</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
return `
|
|
<div class="song-row art" data-action="play" data-ctx="${ctx}" data-index="${index}" data-id="${s.id}">
|
|
<img class="row-art" src="${Art.song(s)}" alt="">
|
|
<div class="row-main">
|
|
<div class="row-title">${esc(s.title)}</div>
|
|
<div class="row-sub">${esc(s.artist)}</div>
|
|
</div>
|
|
<div class="row-album">${esc(s.album)}</div>
|
|
<div class="row-time">${fmt(s.dur)}</div>
|
|
<div class="row-actions">
|
|
<button class="icon-btn ${like ? "liked" : ""}" data-action="like" data-id="${s.id}">${icon(like ? "heartFill" : "heart", 16)}</button>
|
|
<button class="icon-btn" data-action="more" data-id="${s.id}">${icon("dots", 18)}</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function chartRow(s, rank, ctx, index) {
|
|
const cur = Player.current();
|
|
const isCur = cur && cur.id === s.id;
|
|
const like = Store.isLiked(s.id);
|
|
return `
|
|
<div class="song-row chart" data-action="play" data-ctx="${ctx}" data-index="${index}" data-id="${s.id}">
|
|
<span class="row-rank" data-idx-text="${rank}">${isCur ? eqIcon() : rank}</span>
|
|
<img class="row-art" src="${Art.song(s)}" alt="">
|
|
<div class="row-main">
|
|
<div class="row-title">${esc(s.title)}</div>
|
|
<div class="row-sub">${esc(s.artist)}</div>
|
|
</div>
|
|
<div class="row-album">${fmtNum(s.plays)} plays</div>
|
|
<div class="row-time">${fmt(s.dur)}</div>
|
|
<div class="row-actions">
|
|
<button class="icon-btn ${like ? "liked" : ""}" data-action="like" data-id="${s.id}">${icon(like ? "heartFill" : "heart", 16)}</button>
|
|
<button class="icon-btn" data-action="more" data-id="${s.id}">${icon("dots", 18)}</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function section(title, inner, seeAll) {
|
|
return `
|
|
<section class="view-section">
|
|
<div class="section-head">
|
|
<h2 class="section-title">${title}</h2>
|
|
${seeAll ? `<a class="see-all" data-go="${seeAll}">See All</a>` : ""}
|
|
</div>
|
|
<div class="row">${inner}</div>
|
|
</section>`;
|
|
}
|
|
|
|
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 `
|
|
<div class="hero" style="${heroBg(feat.palette)}">
|
|
<img class="hero-art" src="${Art.album(feat)}" alt="">
|
|
<div class="hero-info">
|
|
<div class="hero-kicker">Featured Album</div>
|
|
<h1 class="hero-title">${esc(feat.title)}</h1>
|
|
<div class="hero-sub">${esc(featAlbum.artist)} · ${feat.year} · ${esc(feat.genre)}</div>
|
|
<div class="hero-plays">${fmtNum(D.albumPlays(feat))} plays · ${fmtDurTotal(D.albumSongs(feat.id).reduce((t, s) => t + s.dur, 0))}</div>
|
|
<div class="hero-actions">
|
|
<button class="btn-primary" data-action="play-ctx" data-ctx="${ctxAlbum}">${icon("play", 16)} Play</button>
|
|
<button class="btn-ghost" data-action="shuffle-ctx" data-ctx="${ctxAlbum}">${icon("shuffle", 16)} Shuffle</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
${recent.length ? section("Recently Played", recent.map((s, i) => songCard(s, recCtx, i)).join("")) : ""}
|
|
${section("Made For You", D.mixes.map(mixCard).join(""))}
|
|
<section class="view-section">
|
|
<div class="section-head"><h2 class="section-title">Top Charts</h2></div>
|
|
<div class="list-card">
|
|
${charts.map((s, i) => chartRow(s, i + 1, chartCtx, i)).join("")}
|
|
</div>
|
|
</section>
|
|
${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 `<div class="genre-tile" data-action="open-genre" data-genre="${esc(g)}" style="${heroBg(p)}"><span>${esc(g)}</span></div>`;
|
|
}).join(""))}
|
|
<section class="view-section">
|
|
<div class="section-head"><h2 class="section-title">Top Charts</h2></div>
|
|
<div class="list-card">
|
|
${charts.map((s, i) => chartRow(s, i + 1, ctx, i)).join("")}
|
|
</div>
|
|
</section>
|
|
${section("All Albums", D.albums.slice().sort((a, b) => a.title.localeCompare(b.title)).map(albumCard).join(""))}
|
|
`;
|
|
}
|
|
|
|
function viewRadio() {
|
|
return `
|
|
<div class="hero radio-hero" style="${heroBg(["#fa233b", "#8338ec", "#150a2e"])}">
|
|
<div class="radio-badge">${icon("radio", 22)}</div>
|
|
<div class="hero-info">
|
|
<div class="hero-kicker">Always On</div>
|
|
<h1 class="hero-title">Music Radio</h1>
|
|
<div class="hero-sub">Nonstop stations across every genre. Hit play and let it run.</div>
|
|
<div class="hero-actions">
|
|
<button class="btn-primary" data-action="play-station" data-id="st1">${icon("play", 16)} Start Listening</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
${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 `
|
|
<div class="search-hero">
|
|
<div class="search-box">
|
|
${icon("search", 20)}
|
|
<input id="search-input" type="text" placeholder="Songs, artists, albums, genres…" value="${esc(q)}">
|
|
${q ? `<button class="icon-btn" data-action="clear-search">${icon("x", 18)}</button>` : ""}
|
|
</div>
|
|
</div>
|
|
${!q ? `
|
|
<div class="search-hint">
|
|
<h2>Search for something</h2>
|
|
<p>Try “Aurora”, “Neon”, “jazz”, or an album like “Velvet Hours”.</p>
|
|
</div>` : !res.songs.length && !res.albums.length && !res.artists.length ? `
|
|
<div class="search-hint">
|
|
<h2>No results for “${esc(q)}”</h2>
|
|
<p>Check the spelling or try something else.</p>
|
|
</div>` : `
|
|
${top ? `
|
|
<section class="view-section">
|
|
<div class="section-head"><h2 class="section-title">Top Result</h2></div>
|
|
<div class="top-result" data-action="play" data-ctx="${ctx}" data-index="0">
|
|
<img src="${Art.song(top)}" alt="">
|
|
<div class="tr-main">
|
|
<div class="tr-title">${esc(top.title)}</div>
|
|
<div class="tr-sub">${esc(top.artist)} · ${esc(top.album)}</div>
|
|
</div>
|
|
<button class="btn-primary sm" data-action="play" data-ctx="${ctx}" data-index="0">${icon("play", 14)} Play</button>
|
|
</div>
|
|
</section>` : ""}
|
|
${res.songs.length ? `
|
|
<section class="view-section">
|
|
<div class="section-head"><h2 class="section-title">Songs</h2></div>
|
|
<div class="list-card">${res.songs.map((s, i) => songRow(s, ctx, i, "art")).join("")}</div>
|
|
</section>` : ""}
|
|
${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 `
|
|
<div class="hero detail-hero" style="${heroBg(album.palette)}">
|
|
<img class="hero-art" src="${Art.album(album)}" alt="">
|
|
<div class="hero-info">
|
|
<div class="hero-kicker">Album</div>
|
|
<h1 class="hero-title">${esc(album.title)}</h1>
|
|
<div class="hero-sub"><a data-go="#/artist/${artist.id}" class="link">${esc(artist.name)}</a> · ${album.year} · ${esc(album.genre)}</div>
|
|
<div class="hero-plays">${fmtNum(D.albumPlays(album))} plays · ${fmtDurTotal(totalDur)}</div>
|
|
<div class="hero-actions">
|
|
<button class="btn-primary" data-action="play-ctx" data-ctx="${ctx}">${icon("play", 16)} Play</button>
|
|
<button class="btn-ghost" data-action="shuffle-ctx" data-ctx="${ctx}">${icon("shuffle", 16)} Shuffle</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="list-card">${songs.map((s, i) => songRow(s, ctx, i, "num")).join("")}</div>
|
|
${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 `
|
|
<div class="hero detail-hero artist-hero" style="${heroBg(artist.palette)}">
|
|
<img class="hero-art round" src="${Art.artist(artist)}" alt="">
|
|
<div class="hero-info">
|
|
<div class="hero-kicker">Artist</div>
|
|
<h1 class="hero-title">${esc(artist.name)}</h1>
|
|
<div class="hero-sub">${esc(artist.genre)}</div>
|
|
<div class="hero-plays">${fmtNum(artist.followers)} monthly listeners</div>
|
|
<div class="hero-actions">
|
|
<button class="btn-primary" data-action="play-ctx" data-ctx="${ctx}">${icon("play", 16)} Play Top Songs</button>
|
|
<button class="btn-ghost" data-action="shuffle-ctx" data-ctx="${ctx}">${icon("shuffle", 16)} Shuffle</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="artist-bio">${esc(artist.bio)}</div>
|
|
<div class="list-card">${songs.map((s, i) => songRow(s, ctx, i, "art")).join("")}</div>
|
|
${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 `
|
|
<div class="hero detail-hero" style="${heroBg(mix.palette)}">
|
|
<img class="hero-art" src="${Art.mix(mix)}" alt="">
|
|
<div class="hero-info">
|
|
<div class="hero-kicker">Made For You</div>
|
|
<h1 class="hero-title">${esc(mix.name)}</h1>
|
|
<div class="hero-sub">${esc(mix.desc)}</div>
|
|
<div class="hero-plays">${songs.length} songs · ${fmtDurTotal(songs.reduce((t, s) => t + s.dur, 0))}</div>
|
|
<div class="hero-actions">
|
|
<button class="btn-primary" data-action="play-ctx" data-ctx="${ctx}">${icon("play", 16)} Play</button>
|
|
<button class="btn-ghost" data-action="shuffle-ctx" data-ctx="${ctx}">${icon("shuffle", 16)} Shuffle</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="list-card">${songs.map((s, i) => songRow(s, ctx, i, "art")).join("")}</div>
|
|
`;
|
|
}
|
|
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 `
|
|
<div class="hero detail-hero" style="background-image: linear-gradient(150deg, ${esc(pl.color)}cc, #101014 70%), radial-gradient(120% 130% at 85% 90%, ${esc(pl.color)}88, transparent 55%);">
|
|
<img class="hero-art" src="${art}" alt="">
|
|
<div class="hero-info">
|
|
<div class="hero-kicker">Playlist</div>
|
|
<h1 class="hero-title">${esc(pl.name)}</h1>
|
|
<div class="hero-plays">${songs.length} ${songs.length === 1 ? "song" : "songs"} · created ${new Date(pl.createdAt).toLocaleDateString(undefined, { month: "long", year: "numeric" })}</div>
|
|
<div class="hero-actions">
|
|
<button class="btn-primary" data-action="play-ctx" data-ctx="${ctx}" ${songs.length ? "" : "disabled"}>${icon("play", 16)} Play</button>
|
|
<button class="btn-ghost" data-action="shuffle-ctx" data-ctx="${ctx}" ${songs.length ? "" : "disabled"}>${icon("shuffle", 16)} Shuffle</button>
|
|
<button class="btn-ghost" data-action="rename-playlist" data-id="${pl.id}">${icon("pencil", 15)} Rename</button>
|
|
<button class="btn-ghost danger" data-action="clear-playlist" data-id="${pl.id}" ${songs.length ? "" : "disabled"}>${icon("trash", 15)} Clear</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
${songs.length ? `
|
|
<div class="list-card">${songs.map((s, i) => {
|
|
const row = songRow(s, ctx, i, "art");
|
|
return row;
|
|
}).join("")}</div>` : `
|
|
<div class="empty-state">
|
|
<div class="empty-icon">${icon("music", 30)}</div>
|
|
<h2>This playlist is empty</h2>
|
|
<p>Tap ${icon("dots", 14)} on any song and choose “Add to Playlist”.</p>
|
|
</div>`}
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="hero detail-hero" style="${heroBg(st.palette)}">
|
|
<img class="hero-art" src="${Art.station(st)}" alt="">
|
|
<div class="hero-info">
|
|
<div class="hero-kicker">Radio Station</div>
|
|
<h1 class="hero-title">${esc(st.name)}</h1>
|
|
<div class="hero-sub">${esc(st.desc)}</div>
|
|
<div class="hero-plays">${songs.length} tracks in rotation</div>
|
|
<div class="hero-actions">
|
|
<button class="btn-primary" data-action="play-station" data-id="${st.id}">${icon("radio", 16)} Tune In</button>
|
|
<button class="btn-ghost" data-action="shuffle-ctx" data-ctx="${ctx}">${icon("shuffle", 16)} Shuffle</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="list-card">${songs.slice(0, 25).map((s, i) => songRow(s, ctx, i, "art")).join("")}</div>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="hero detail-hero" style="${heroBg(p)}">
|
|
<div class="genre-badge">${icon("sparkle", 24)}</div>
|
|
<div class="hero-info">
|
|
<div class="hero-kicker">Genre</div>
|
|
<h1 class="hero-title">${esc(genre)}</h1>
|
|
<div class="hero-plays">${songs.length} songs · ${fmtNum(songs.reduce((t, s) => t + s.plays, 0))} total plays</div>
|
|
<div class="hero-actions">
|
|
<button class="btn-primary" data-action="play-ctx" data-ctx="${ctx}">${icon("play", 16)} Play</button>
|
|
<button class="btn-ghost" data-action="shuffle-ctx" data-ctx="${ctx}">${icon("shuffle", 16)} Shuffle</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="list-card">${songs.slice(0, 15).map((s, i) => songRow(s, ctx, i, "art")).join("")}</div>
|
|
${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 `
|
|
<div class="hero detail-hero" style="${heroBg(["#fa233b", "#fb5c74", "#2d0a12"])}">
|
|
<div class="hero-art center-art">${icon("heartFill", 90)}</div>
|
|
<div class="hero-info">
|
|
<div class="hero-kicker">Playlist</div>
|
|
<h1 class="hero-title">Liked Songs</h1>
|
|
<div class="hero-sub">Every song you've hearted, in one place.</div>
|
|
<div class="hero-plays">${songs.length} ${songs.length === 1 ? "song" : "songs"}</div>
|
|
<div class="hero-actions">
|
|
<button class="btn-primary" data-action="play-ctx" data-ctx="${ctx}" ${songs.length ? "" : "disabled"}>${icon("play", 16)} Play</button>
|
|
<button class="btn-ghost" data-action="shuffle-ctx" data-ctx="${ctx}" ${songs.length ? "" : "disabled"}>${icon("shuffle", 16)} Shuffle</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
${songs.length ? `<div class="list-card">${songs.map((s, i) => songRow(s, ctx, i, "art")).join("")}</div>` : `
|
|
<div class="empty-state">
|
|
<div class="empty-icon">${icon("heart", 30)}</div>
|
|
<h2>No liked songs yet</h2>
|
|
<p>Tap the heart on any song to save it here.</p>
|
|
</div>`}
|
|
`;
|
|
}
|
|
|
|
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 ? `<div class="row">${pls.map(plCard).join("")}</div>` : `
|
|
<div class="empty-state">
|
|
<div class="empty-icon">${icon("queue", 30)}</div>
|
|
<h2>No playlists yet</h2>
|
|
<p>Create one and start collecting songs.</p>
|
|
<button class="btn-primary" data-action="create-playlist">${icon("plus", 15)} New Playlist</button>
|
|
</div>`}`;
|
|
} else if (tab === "artists") {
|
|
body = `<div class="row grid-row">${D.artists.map(artistCard).join("")}</div>`;
|
|
} else if (tab === "albums") {
|
|
body = `<div class="row grid-row">${D.albums.slice().sort((a, b) => a.title.localeCompare(b.title)).map(albumCard).join("")}</div>`;
|
|
} 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 = `<div class="list-card">${songs.map((s, i) => songRow(s, ctx, i, "art")).join("")}</div>`;
|
|
}
|
|
return `
|
|
<h1 class="page-title">Library</h1>
|
|
<div class="tabs">
|
|
${tabs.map(([t, label]) => `<button class="tab ${t === tab ? "active" : ""}" data-action="open-library" data-tab="${t}">${label}</button>`).join("")}
|
|
</div>
|
|
${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 = `
|
|
<div class="modal-backdrop">
|
|
<div class="modal">${html}</div>
|
|
</div>`;
|
|
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 `
|
|
<h3>${pl ? "Rename Playlist" : "New Playlist"}</h3>
|
|
<input class="modal-input" type="text" placeholder="Playlist name" value="${pl ? esc(pl.name) : ""}" maxlength="40">
|
|
<div class="swatches">
|
|
${colors.map((c, i) => `<button class="swatch ${pl ? (pl.color === c ? "sel" : "") : i === 0 ? "sel" : ""}" data-color="${c}" style="background:${c}"></button>`).join("")}
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn-ghost" data-action="modal-close">Cancel</button>
|
|
<button class="btn-primary" data-action="${pl ? "modal-rename-save" : "modal-create-save"}">${pl ? "Save" : "Create"}</button>
|
|
</div>`;
|
|
}
|
|
|
|
function openMenu(x, y, items) {
|
|
closeMenu();
|
|
const root = $("#menu-root");
|
|
root.innerHTML = `<div class="menu">${items.map((it, i) => `
|
|
${it.sep ? `<div class="menu-sep"></div>` : `
|
|
<button class="menu-item ${it.danger ? "danger" : ""}" data-midx="${i}">${icon(it.icon, 15)}<span>${esc(it.label)}</span></button>`}`).join("")}</div>`;
|
|
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();
|