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 = `${ICONS[name]}`; + 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 = ` + + + + + + + + `; +} + +function renderPlayer() { + $("#player").innerHTML = ` +
+
+ +
${eqIcon()}
+
+
+
Nothing playing
+
Pick a song to start
+
+ +
+
+
+ + + + + +
+
+ 0:00 +
+ 0:00 +
+
+
+ + +
+
+
+
+ `; +} + +function renderNow() { + $("#now").innerHTML = ` +
+ +
+
+
+
+ + +
+
+
+
+
+
Nothing playing
+
+
+
+ + + + + + + +
+
+ 0:00 +
+ 0:00 +
+
+ +
+
+
+
+ `; +} + +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 ` +
+ +
+ ${!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)}

+
${esc(artist.name)} · ${album.year} · ${esc(album.genre)}
+
${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();