1/* ===================================================================== 2 * Magnatune web app â client (P1: browse) 3 * ===================================================================== 4 * Catalog is read entirely client-side via sql.js. No audio yet (P2), 5 * no favorites/playlists yet (P3). Covers/photos load through the Phase-A 6 * HTTPS image proxy (cfg.image_base = "img.adp"). 7 * ===================================================================== */ 8"use strict"; 9 10const MAG = { cfg: null, db: null, lists: {}, nowPlayingOpen: false }; 11const el = (id) => document.getElementById(id); 12const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => 13 ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); 14 15/* ---- pretty hash URLs ---- 16 * Routes use human-readable name slugs instead of numeric ids: 17 * #/artist/<artist> #/album/<artist>/<album> #/genre/<g> #/tag/<t> #/featured/<f> 18 * slug() lowercases and hyphenates; all magnatune names slugify uniquely (verified against 19 * the catalog: artists/genres/tags/featured fully unique, albums unique as artist/album). 20 * Slug holds the reverse slug->id maps, built once after the catalog loads (buildSlugMaps). 21 * URL builders are the single source of truth for every internal link. */ 22const slug = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); 23const Slug = { artist: new Map(), album: new Map(), genre: new Map(), tag: new Map(), featured: new Map() }; 24const artistUrl = (name) => `#/artist/${slug(name)}`; 25const albumUrl = (artist, name) => `#/album/${slug(artist)}/${slug(name)}`; 26const genreUrl = (name) => `#/genre/${slug(name)}`; 27const tagUrl = (name) => `#/tag/${slug(name)}`; 28const featuredUrl = (name) => `#/featured/${slug(name)}`; 29const songUrl = (artist, album, title) => `${albumUrl(artist, album)}/${slug(title)}`; 30 31/* ===================== PATH routing mode (flag-gated) ========================== 32 * In navim4 the whole site is served by NaviServer at REAL paths (no "#", no /app/): 33 * the prerendered SEO page boots this app and the app takes over the SAME real URL via 34 * the History API. Gated on window.MAG_ROUTING==='path' â DEFAULT (unset) is 100% 35 * hash-routed, so this deployed app is unaffected; navim4 sets the flag to serve the 36 * whole site at real URLs. The app builds "#/â¦" links everywhere and translates only at 37 * the address-bar boundary (clicks/boot/popstate) via the token<->path maps below. */ 38const PATH_MODE = (typeof window !== "undefined" && window.MAG_ROUTING === "path"); 39// real-path slug variants matching navim4's Tcl (strip_spaces / replace_spaces_with_underscore) 40const srvGenreSlug = (s) => String(s ?? "").toLowerCase().replace(/ /g, ""); 41const srvCollSlug = (s) => String(s ?? "").toLowerCase().replace(/ /g, "_"); 42// token<->real-path lookup maps, populated in buildSlugMaps (PATH mode only). 43const P = { 44 pathByArtist: new Map(), // artistSlug -> "/artists/<page>/" 45 tokenByPage: new Map(), // <page> -> "artist/<artistSlug>" 46 pathByAlbum: new Map(), // "aSlug/bSlug" -> "/artists/albums/<sku>/" 47 tokenBySku: new Map(), // <sku> -> "album/<aSlug>/<bSlug>" 48 pathByGenre: new Map(), // genreSlug -> "/genres/<srv>/" 49 tokenByGenre: new Map(), // <srv> -> "genre/<genreSlug>" 50 pathByTag: new Map(), // tagSlug -> "/collections/<srv>" 51 tokenByColl: new Map(), // <srv> -> "tag/<tagSlug>" 52}; 53// SettingsâAbout pages keep magnatune.com's REAL /info/ URLs (not /player/...). 54const INFO_URL = { 55 whynotevil: "/info/whynotevil", faq: "/info/faq_buyers", licensing: "/info/licensing", 56 give: "/info/give", why: "/info/why", podcast: "/info/podcast", api: "/info/api", 57 contact: "/info/contact", giftcards: "/buy/gift_cards", terms: "/terms_of_use", 58}; 59const INFO_PATH = {}; // reverse: real path -> info key 60for (const k in INFO_URL) INFO_PATH[INFO_URL[k]] = k; 61// An app "token" is a route minus the leading "#/" (e.g. "album/beth-quist/aegean-dreams"). 62// Detail tokens map through P; overviews use magnatune.com's real bare-dir URLs; everything 63// else (favorites, playlists, search, settings, featured) lives under /player/. 64function tokenToPath(tok) { 65 const p = (tok || "").replace(/^[#/]+/, "").split("/").filter(
65Boolean); 66 const sec = p[0] || "popular"; 67 switch (sec) { 68 case "popular": return "/"; 69 case "artist": return p[1] ? (P.pathByArtist.get(p[1]) || "/artists/") : "/artists/"; 70 case "album": { 71 if (!p[1]) return "/artists/albums/"; 72 const base = P.pathByAlbum.get(p[1] + "/" + (p[2] || "")); 73 if (!base) return "/artists/albums/"; 74 return p[3] ? base + p[3] : base; // song slug appends to "â¦/<sku>/" 75 } 76 case "genre": return p[1] ? (P.pathByGenre.get(p[1]) || "/genres/") : "/genres/"; 77 case "tag": return p[1] ? (P.pathByTag.get(p[1]) || "/collections/") : "/collections/"; 78 case "info": return (p[1] && INFO_URL[p[1]]) || ("/player/info/" + (p[1] || "")); // real /info/ URLs 79 default: return "/player/" + p.join("/"); // featured/favorites/playlists/playlist/search/settings 80 } 81} 82function pathToToken(pathname) { 83 const p = decodeURIComponent(pathname || "/").replace(/^\/+|\/+$/g, "").split("/").filter(Boolean); 84 if (p.length === 0) return "popular"; 85 const realInfo = INFO_PATH["/" + p.join("/")]; // the About pages' real /info/ URLs 86 if (realInfo) return "info/" + realInfo; 87 if (p[0] === "player") return p.slice(1).join("/") || "popular"; 88 if (p[0] === "artists" && p[1] === "albums") { 89 const tok = P.tokenBySku.get(p[2]); 90 if (!tok) return "album"; 91 return p[3] ? tok + "/" + p[3] : tok; // trailing song slug 92 } 93 if (p[0] === "artists") return P.tokenByPage.get(p[1]) || "artist"; 94 if (p[0] === "genres") return P.tokenByGenre.get(p[1]) || "genre"; 95 if (p[0] === "collections") return P.tokenByColl.get(p[1]) || "tag"; 96 return "popular"; 97} 98// current app route token from the address bar (path OR hash), normalized (no leading slash). 99function currentToken() { 100 const t = PATH_MODE ? pathToToken(location.pathname) : (location.hash || "#/").slice(1); 101 return t.replace(/^\/+/, ""); 102} 103// navigate to a token. hash mode: set the hash (fires hashchangeâroute). path mode: 104// pushState/replaceState the real path, then route() manually. 105function go(tok, replace) { 106 tok = tok.replace(/^[#/]+/, ""); 107 if (PATH_MODE) { 108 history[replace ? "replaceState" : "pushState"](null, "", tokenToPath(tok) + location.search); 109 route(); 110 } else if (replace) { 111 setHash("#/" + tok); 112 } else { 113 location.hash = "#/" + tok; 114 } 115} 116MAG.go = (u) => go(u); // onclick navigation helper (works in both hash and PATH routing modes) 117// replaceState a new "#/..." hash WITHOUT going through the document base URL (hash mode); 118// in PATH mode, replaceState the equivalent real path instead. 119const setHash = (h) => PATH_MODE 120 ? history.replaceState(null, "", tokenToPath(h) + location.search) 121 : history.replaceState(null, "", location.pathname + location.search + h); 122// Reflect the now-playing song in the address bar â but only while the user is actually 123// viewing that song's album page, so playing from a playlist/search/artist page doesn't 124// hijack their location. replaceState (not location.hash=) so it doesn't fire a re-render. 125function syncSongInUrl(t) { 126 if (!t || !t.artist || !t.album) return; 127 const base = albumUrl(t.artist, t.album).replace(/^[#/]+/, ""); // token "album/a/b" 128 const cur = currentToken(); 129 if (cur === base || cur.startsWith(base + "/")) { 130 const want = songUrl(t.artist, t.album, t.title).replace(/^[#/]+/, ""); 131 if (cur !== want) go(want, true); // reflect the song without adding a history entry 132 } 133} 134function buildSlugMaps() { 135 const set = (m, name, id) => { const s = slug(name); if (s && !m.has(s)) m.set(s, id); }; 136 for (const r of q("select artists_id,name,page from artists")) { 137 set(Slug.artist, r.name, r.artists_id); 138 if (PATH_MODE && r.page) { const a = slug(r.name); P.pathByArtist.set(a, `/artists/${r.page}/`); P.tokenByPage.set(r.page, `artist/${a}`); } 139 } 140 // album key is the composite "artistslug/albumslug" â insert directly so the "/" survives 141 // (set() would re-slugify and collapse the slash into a hyphen) 142 for (const r of q("select a.album_id,a.name,a.sku,ar.name artist from albums a join artists ar on ar.artists_id=a.artist_id")) {
143 const k = slug(r.artist) + "/" + slug(r.name); 144 if (!Slug.album.has(k)) Slug.album.set(k, r.album_id); 145 if (PATH_MODE && r.sku) { P.pathByAlbum.set(k, `/artists/albums/${r.sku}/`); P.tokenBySku.set(r.sku, `album/${k}`); } 146 } 147 for (const r of q("select genre_id,name from genres")) { 148 set(Slug.genre, r.name, r.genre_id); 149 if (PATH_MODE) { const g = slug(r.name), s = srvGenreSlug(r.name); P.pathByGenre.set(g, `/genres/${s}/`); P.tokenByGenre.set(s, `genre/${g}`); } 150 } 151 for (const r of q("select collections_id,name from collections")) { 152 set(Slug.tag, r.name, r.collections_id); 153 if (PATH_MODE) { const c = slug(r.name), s = srvCollSlug(r.name); P.pathByTag.set(c, `/collections/${s}`); P.tokenByColl.set(s, `tag/${c}`); } 154 } 155 for (const r of q("select playlist_id,name from playlists where owner_id=0")) set(Slug.featured, r.name, r.playlist_id); 156} 157 158/* ---- asset URL builders ---- 159 * Covers AND audio are served SAME-ORIGIN by navim4's /music/* handler 160 * (m3_music_serve: local /music disk on production, reverse-proxy in dev) â no 161 * external he3 dependency, and no mixed content when the site is HTTPS. Absolute 162 * path (leading /) so <base href="/app/"> does not rewrite it. 163 * Files are magnatune's pre-generated thumbnails; pick the smallest per context. */ 164const MEDIA_BASE = "/music/"; 165// Membership credential check (server-side, because the browser can't replay 166// HTTP Basic auth itself). Same-origin: navim4 serves this natively (m3_check_member, 167// local MySQL) â no external dependency. It only flips the member UI; real downloads 168// stay member-gated by m3_verify_member on /music and /membership/download3. 169const LOGIN_URL = "/membership/check.php"; 170// Catalog is fetched as BYTES, so it's CORS-gated â unlike covers/audio (which 171// load via <img>/<audio> and work cross-origin). he3 sends no CORS header and is 172// a different origin from the app anyway, so the catalog must be SAME-ORIGIN: a 173// Catalog is fetched as BYTES â CORS-gated (unlike covers/audio, which load via 174// <img>/<audio> and work cross-origin). On magnatune.com it's a SAME-ORIGIN fetch of 175// magnatune's own published files (/info/...): always current, nothing shipped in the 176// repo. On any other host (local dev) /info/ won't exist and a cross-origin fetch of 177// magnatune.com is CORS-blocked, so fall back to app-dir copies (untracked dev-only 178// files). The catalog is gzipped (~2.6MB) â inflated in-browser by gunzip(). 179const ON_MAGNATUNE = /(^|\.)magnatune\.com$/i.test(location.hostname); 180// The catalog now ships as two compact gzipped TEXT files instead of a SQLite db: 181// sqlite_core.txt.gz wave 1 â everything needed to browse/search/play 182// (NO album descriptions / artist bios). ~0.6MB gz. 183// sqlite_descriptions.txt.gz wave 2 â album notes + artist bios, loaded async. 184// A JS importer (importCatalog / importDescriptions) builds an in-memory sql.js db 185// with the SAME schema the app has always used, so every query is unchanged. 186const CATALOG_URL = ON_MAGNATUNE ? "/info/sqlite_core.txt.gz" : "sqlite_core.txt.gz"; 187const DESC_URL = ON_MAGNATUNE ? "/info/sqlite_descriptions.txt.gz" : "sqlite_descriptions.txt.gz"; 188const CRC_URL = ON_MAGNATUNE ? "/info/changed.txt" : "catalog.crc"; 189function imgURL(artist, album, file) { 190 if (!artist || !album) return ""; 191 // ?v=catalogVersion busts any stale/poisoned browser cache after a fix or catalog 192 // change; the server proxy ignores v (its disk cache keys on artist/album/file only). 193 const v = MAG.cfg ? MAG.cfg.catalog_version : 0; 194 return MEDIA_BASE + encodeURIComponent(artist) + "/" + encodeURIComponent(album) + "/" + file + "?v=" + v; 195} 196// smallest sensible magnatune thumbnail per UI situation 197const COVER = { cell: "cover_200.webp", detail: "cover_400.webp", zoom: "cover_1400.webp" }; 198const ARTIST = { cell: "artist_200.webp", detail: "artist_420.webp" }; 199 200/* ---- image fallback: tiers + negative cache ---- 201 * Not every album directory on he3 has every sized thumbnail generated (and webp exists only 202 * at some sizes), so a given cover_/artist_ file 404s for some albums/artists â leav
202ing a 203 * broken image. We fall back through the other tiers (preferring jpg, the complete family), 204 * and remember misses in localStorage so a 404'd tier isn't re-requested: the available size 205 * (already browser-cached) then loads instantly on later views and across page reloads. 206 * The webp sets below are MEASURED off /music (2026-07-20), not guessed â an entry that is 207 * not really on disk costs every viewer a 404 before the fallback. cover has no webp at 208 * 50/75/600; artist has webp at every size it ships. jpg stays in the chain as the backstop 209 * for the handful of albums whose source image cwebp could not read (CMYK jpegs). */ 210const TIERS = { 211 cover: { sizes: [50, 75, 100, 150, 200, 300, 400, 600, 800, 1400], webp: new Set([100, 150, 200, 300, 400, 800, 1400]) }, 212 artist: { sizes: [50, 200, 420, 840], webp: new Set([50, 200, 420, 840]) }, 213}; 214// Ordered candidate files for an ideal file: the exact file first, then same-size jpg, then 215// larger sizes, then smaller â jpg always included (webp only where it exists on the server). 216function fallbackFiles(file) { 217 const m = /^(cover|artist)_(\d+)\.(webp|jpg)$/.exec(file); 218 if (!m) return [file]; 219 const kind = m[1], ideal = +m[2], t = TIERS[kind]; 220 const order = [ideal, ...t.sizes.filter((s) => s > ideal), ...t.sizes.filter((s) => s < ideal).reverse()]; 221 const files = []; 222 const push = (f) => { if (!files.includes(f)) files.push(f); }; 223 push(file); 224 for (const s of order) { 225 if (t.webp.has(s)) push(`${kind}_${s}.webp`); 226 push(`${kind}_${s}.jpg`); 227 } 228 return files; 229} 230const ImgMiss = { 231 KEY: "magnatune.imgmiss", _set: null, 232 _load() { 233 if (!this._set) { 234 try { this._set = new Set(JSON.parse(localStorage.getItem(this.KEY) || "[]")); } 235 catch (e) { this._set = new Set(); } 236 } 237 return this._set; 238 }, 239 _k(a, al, f) { return a + "|" + al + "|" + f; }, 240 has(a, al, f) { return this._load().has(this._k(a, al, f)); }, 241 add(a, al, f) { 242 const s = this._load(), k = this._k(a, al, f); 243 if (s.has(k)) return; 244 s.add(k); 245 try { localStorage.setItem(this.KEY, JSON.stringify([...s])); } catch (e) {} 246 }, 247}; 248// A near-empty SVG used as the initial src for throttled-lazy images, so the 249// cell shows its grey background (no broken-image icon) until the real cover 250// is swapped in by LazyImg. Immutable / cached by the browser after the first. 251const IMG_PLACEHOLDER = 252 "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'/%3E"; 253 254// Build an <img> that walks the fallback chain on error. opts.eager loads the 255// real cover immediately; otherwise the URL is parked in data-src and LazyImg 256// only fetches it once the image has SETTLED in/near the viewport (see below), 257// so a fast flick down the grid never queues the covers it flies past. 258function imgTag(artist, album, file, opts = {}) { 259 if (!artist || !album) return ""; 260 const all = fallbackFiles(file); 261 const live = all.filter((f) => !ImgMiss.has(artist, album, f)); 262 const list = live.length ? live : all; 263 const cur = list[0], rest = list.slice(1); 264 const url = imgURL(artist, album, cur); 265 const srcAttr = opts.eager 266 ? `src="${url}"` 267 : `src="${IMG_PLACEHOLDER}" data-src="${url}"`; 268 return `<img alt="" data-art="${esc(artist)}" data-alb="${esc(album)}"` + 269 ` data-cur="${esc(cur)}" data-fb="${esc(JSON.stringify(rest))}"` + 270 ` ${srcAttr} onerror="MAG.imgFail(this)">`; 271} 272 273/* ---- throttled lazy image loader -------------------------------------------- 274 * Native loading="lazy" starts a fetch for EVERY image that crosses the viewport 275 * while scrolling. A fast flick down the album grid therefore queues 100+ cover 276 * requests onto HTTP/1.1's ~6 connections, and the last ones land seconds later. 277 * 278 * LazyImg loads a cover only once it has *settled* in (or within 400px of) the 279 * viewport for ~120ms. An image that merely flashes past during a fast scroll is 280 * never requested â its pending load is cancelled the moment it leaves. When you 281 * stop, whatever is on screen loads promptly. Eager images (Now Playing, 282 * mini-player, zoom) bypass this entirely and load at once. 283 * 284 * A MutationObserver on #content auto-registers images added by later renders 285 * (search-as-you-type, live filters), so callers don't have to remember to scan. */
286const LazyImg = { 287 io: null, mo: null, SETTLE: 120, MARGIN: 400, 288 // #content is the scroll container (overflow:auto), so intersection is measured 289 // against IT, not the window â correct for this layout and independent of any 290 // window-height quirk. Re-checked at settle time to skip flicked-past images. 291 _visible(img) { 292 const root = el("content"); 293 if (!root) return true; 294 const r = img.getBoundingClientRect(), rr = root.getBoundingClientRect(); 295 return r.width > 0 && r.bottom > rr.top - this.MARGIN && r.top < rr.bottom + this.MARGIN; 296 }, 297 _load(img) { 298 const u = img.dataset.src; 299 if (!u) return; 300 delete img.dataset.src; 301 if (this.io) this.io.unobserve(img); 302 if (img._lt) { clearTimeout(img._lt); img._lt = 0; } 303 img.src = u; // a 404 here still triggers onerror â MAG.imgFail (fallback walk) 304 }, 305 init() { 306 if (this.io || !("IntersectionObserver" in window)) return; 307 this.io = new IntersectionObserver((entries) => { 308 for (const e of entries) { 309 const img = e.target; 310 if (e.isIntersecting) { 311 if (img._lt) continue; 312 img._lt = setTimeout(() => { 313 img._lt = 0; 314 if (img.dataset.src && this._visible(img)) this._load(img); 315 }, this.SETTLE); 316 } else if (img._lt) { 317 clearTimeout(img._lt); img._lt = 0; // flew past before settling â skip it 318 } 319 } 320 }, { root: el("content"), rootMargin: this.MARGIN + "px 0px", threshold: 0 }); 321 const content = el("content"); 322 if (content && "MutationObserver" in window) { 323 this.mo = new MutationObserver((muts) => { 324 for (const m of muts) 325 for (const n of m.addedNodes) { 326 if (n.nodeType !== 1) continue; 327 if (n.matches && n.matches("img[data-src]")) this.io.observe(n); 328 if (n.querySelectorAll) n.querySelectorAll("img[data-src]").forEach((i) => this.io.observe(i)); 329 } 330 }); 331 this.mo.observe(content, { childList: true, subtree: true }); 332 } 333 }, 334 scan(root) { 335 this.init(); 336 const imgs = (root || document).querySelectorAll("img[data-src]"); 337 if (!this.io) { imgs.forEach((i) => this._load(i)); return; } // no IO â just load all 338 imgs.forEach((i) => this.io.observe(i)); 339 }, 340}; 341 342/* ---- sql.js query helper ---- */ 343function q(sql, params = []) { 344 const st = MAG.db.prepare(sql); 345 st.bind(params); 346 const rows = []; 347 while (st.step()) rows.push(st.getAsObject()); 348 st.free(); 349 return rows; 350} 351const q1 = (sql, params = []) => q(sql, params)[0]; 352 353/* ---- named catalog queries ---- */ 354const Q = { 355 genres: () => q("select genre_id,name from genres order by name collate nocase"), 356 popularInGenre: (gid, lim) => q( 357 "select a.album_id,a.name,a.popularity,ar.name artist from albums a " + 358 "join genres_albums ga on ga.album_id=a.album_id " + 359 "join artists ar on ar.artists_id=a.artist_id " + 360 "where ga.genre_id=? order by a.popularity desc limit ?", [gid, lim]), 361 // sort: "recent" | "popular" | anything else (default) = alphabetical. 362 // Artists have no date/popularity of their own, so Recent/Popular sort by the artist's 363 // newest / most-popular album (MAX across their albums). 364 allArtists: (sort) => q( 365 "select ar.artists_id,ar.name," + 366 " (select name from albums where artist_id=ar.artists_id order by popularity desc, album_id limit 1) album" + 367 " from artists ar left join albums a on a.artist_id=ar.artists_id group by ar.artists_id order by " + 368 (sort === "recent" ? "max(a.release_date) desc, ar.name collate nocase" 369 : sort === "popular" ? "max(a.popularity) desc, ar.name collate nocase" 370 : "ar.name collate nocase")), 371 allAlbums: (sort) => q( 372 "select a.album_id,a.name,ar.name artist from albums a " + 373 "join artists ar on ar.artists_id=a.artist_id order by " + 374 (sort === "recent" ? "a.release_date desc, a.name collate nocase" 375 : sort === "popular" ? "a.popularity desc, a.name collate nocase" 376 : "a.name collate nocase")), 377 genreArtists: (gid) => q( 378 "select distinct ar.artists_id,ar.name," + 379 " (select name from albums where artist_id=ar.artists_id order by popularity desc, album_id limit 1) album" + 380 " from artists ar join albums a on a.artist_id=ar.artists_id " + 381 "join genres_albums ga on ga.album_id=a.album_id " +
382 "where ga.genre_id=? order by ar.name collate nocase", [gid]), 383 collections: () => q( 384 "select c.collections_id,c.name,count(ca.album_id) cnt from collections c " + 385 "join collections_albums ca on ca.collection_id=c.collections_id " + 386 "group by c.collections_id order by c.name collate nocase"), 387 collectionAlbums: (cid) => q( 388 "select a.album_id,a.name,ar.name artist from albums a " + 389 "join collections_albums ca on ca.album_id=a.album_id " + 390 "join artists ar on ar.artists_id=a.artist_id " + 391 "where ca.collection_id=? order by a.name collate nocase", [cid]), 392 // Every song in a genre / tag, ranked by album popularity then track order â for the 393 // category "Play"/"Shuffle" buttons. Same column shape as playlistSongs (trk() reads it). 394 genreSongs: (gid) => q( 395 "select s.song_id,s.name,s.duration,s.mp3,al.album_id,al.name album,ar.name artist " + 396 "from genres_albums ga join albums al on al.album_id=ga.album_id " + 397 "join artists ar on ar.artists_id=al.artist_id " + 398 "join songs s on s.album_id=al.album_id " + 399 "where ga.genre_id=? order by al.popularity desc, al.album_id, s.track_no", [gid]), 400 collectionSongs: (cid) => q( 401 "select s.song_id,s.name,s.duration,s.mp3,al.album_id,al.name album,ar.name artist " + 402 "from collections_albums ca join albums al on al.album_id=ca.album_id " + 403 "join artists ar on ar.artists_id=al.artist_id " + 404 "join songs s on s.album_id=al.album_id " + 405 "where ca.collection_id=? order by al.popularity desc, al.album_id, s.track_no", [cid]), 406 featured: () => q("select playlist_id,name from playlists where owner_id=0 order by sort_order"), 407 playlistSongs: (pid) => q( 408 "select s.song_id,s.name,s.duration,s.mp3,al.album_id,al.name album,ar.name artist " + 409 "from playlist_songs ps join songs s on s.song_id=ps.song_id " + 410 "join albums al on al.album_id=s.album_id " + 411 "join artists ar on ar.artists_id=al.artist_id " + 412 "where ps.playlist_id=? order by ps.sort_order", [pid]), 413 artist: (id) => q1("select artists_id,name,description,bio,city,state,country from artists where artists_id=?", [id]), 414 artistAlbums: (id) => q("select album_id,name from albums where artist_id=? order by release_date desc", [id]), 415 album: (id) => q1( 416 "select a.album_id,a.name,a.description,a.sku,a.release_date,a.artist_id,ar.name artist " + 417 "from albums a join artists ar on ar.artists_id=a.artist_id where a.album_id=?", [id]), 418 albumSongs: (id) => q("select song_id,name,track_no,duration,mp3 from songs where album_id=? order by track_no", [id]), 419 albumGenres: (id) => q("select g.genre_id,g.name from genres g join genres_albums ga on ga.genre_id=g.genre_id where ga.album_id=? order by g.name", [id]), 420 // Artist-level genres/tags: distinct genres/collections aggregated across all of this artist's albums (chips on the artist page, same as the album page). 421 artistGenres: (id) => q("select distinct g.genre_id,g.name from genres g join genres_albums ga on ga.genre_id=g.genre_id join albums a on a.album_id=ga.album_id where a.artist_id=? order by g.name", [id]), 422 artistTags: (id) => q("select distinct c.collections_id,c.name from collections c join collections_albums ca on ca.collection_id=c.collections_id join albums a on a.album_id=ca.album_id where a.artist_id=? order by c.name", [id]), 423 // "You might also like" â the magnatune recommendation engine's ranked sibling albums for an album. 424 albumRecs: (id) => q( 425 "select a.album_id,a.name,ar.name artist from recommendations r " + 426 "join albums a on a.album_id=r.recommended_album_id " + 427 "join artists ar on ar.artists_id=a.artist_id " + 428 "where r.album_id=? order by r.rank", [id]), 429 // Artist-level recommendations: aggregate the per-album recs across all of this artist's albums, 430 // tally which OTHER artists are recommended most (each rec = one vote), drop the artist itself. 431 artistRecArtists: (id) => q( 432 "select ar.artists_id,ar.name,count(*) score," + 433 " (select name from albums where artist_id=ar.artists_id order by popularity desc, album_id limit 1) album" + 434 " from recommendations r " +
435 "join albums ra on ra.album_id=r.recommended_album_id " + 436 "join artists ar on ar.artists_id=ra.artist_id " + 437 "where r.album_id in (select album_id from albums where artist_id=?) and ra.artist_id<>? " + 438 "group by ar.artists_id order by score desc, ar.name collate nocase limit 16", [id, id]), 439 albumTags: (id) => q("select c.collections_id,c.name from collections c join collections_albums ca on ca.collection_id=c.collections_id where ca.album_id=? order by c.name", [id]), 440 searchArtists: (t) => q( 441 "select artists_id,name," + 442 " (select name from albums where artist_id=artists.artists_id order by popularity desc, album_id limit 1) album" + 443 " from artists where name like ? or description like ? or bio like ? order by name collate nocase limit 40", 444 ["%" + t + "%", "%" + t + "%", "%" + t + "%"]), 445 searchAlbums: (t) => q( 446 "select a.album_id,a.name,ar.artists_id artist_id,ar.name artist from albums a join artists ar on ar.artists_id=a.artist_id " + 447 "where a.name like ? or a.description like ? order by a.name collate nocase limit 40", 448 ["%" + t + "%", "%" + t + "%"]), 449 searchSongs: (t) => q( 450 "select s.song_id,s.name,s.mp3,s.duration,al.album_id,al.name album,ar.artists_id artist_id,ar.name artist from songs s " + 451 "join albums al on al.album_id=s.album_id join artists ar on ar.artists_id=al.artist_id " + 452 "where s.name like ? order by s.name collate nocase limit 60", ["%" + t + "%"]), 453 // ---- by-id lookups for favorites / playlists (localStorage stores only ids) ---- 454 songsByIds: (ids) => ids.length ? q( 455 "select s.song_id,s.name,s.mp3,s.duration,s.track_no,al.album_id,al.name album,ar.artists_id artist_id,ar.name artist from songs s " + 456 "join albums al on al.album_id=s.album_id join artists ar on ar.artists_id=al.artist_id " + 457 `where s.song_id in (${ids.map(() => "?").join(",")})`, ids) : [], 458 albumsByIds: (ids) => ids.length ? q( 459 "select a.album_id,a.name,ar.name artist from albums a join artists ar on ar.artists_id=a.artist_id " + 460 `where a.album_id in (${ids.map(() => "?").join(",")})`, ids) : [], 461 artistsByIds: (ids) => ids.length ? q( 462 "select ar.artists_id,ar.name," + 463 " (select name from albums where artist_id=ar.artists_id order by popularity desc, album_id limit 1) album" + 464 ` from artists ar where ar.artists_id in (${ids.map(() => "?").join(",")})`, ids) : [], 465 artistSongIds: (id) => q( 466 "select s.song_id from songs s join albums a on a.album_id=s.album_id " + 467 "where a.artist_id=? order by a.release_date desc, s.track_no", [id]).map((r) => r.song_id), 468}; 469 470/* ---- formatting + components ---- */ 471const fmtDur = (s) => { 472 s = Math.round(s || 0); 473 return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); 474}; 475// On image load failure: remember the miss, then try the next file in the fallback chain; 476// when the chain is exhausted, hide the image and reveal any sibling .no-photo placeholder. 477MAG.imgFail = function (img) { 478 const a = img.dataset.art, al = img.dataset.alb, cur = img.dataset.cur; 479 if (a && al && cur) ImgMiss.add(a, al, cur); 480 let rest = []; 481 try { rest = JSON.parse(img.dataset.fb || "[]"); } catch (e) {} 482 if (rest.length) { 483 const next = rest.shift(); 484 img.dataset.cur = next; 485 img.dataset.fb = JSON.stringify(rest); 486 img.src = imgURL(a, al, next); 487 } else { 488 img.onerror = null; 489 img.style.display = "none"; 490 const ph = img.parentElement && img.parentElement.querySelector(".no-photo"); 491 if (ph) ph.style.display = ""; 492 } 493}; 494 495function albumCell(a, opts) { 496 if (Suppress.album(a.album_id)) return ""; // hidden by "Hide things I dislike" 497 // opts.icon â small disc icon before the name (used in search results). opts is a number 498 // when called via .map (the index), which has no .icon, so other grids are unaffected. 499 const ic = opts && opts.icon ? `<i class="fa-solid fa-compact-disc cell-ic"></i> ` : ""; 500 return `<a class="cell album-cell" href="${albumUrl(a.artist, a.name)}"> 501 <div class="cover">${imgTag(a.artist, a.name, COVER.cell)}</div> 502 <div class="cell-name">${ic}${esc(a.name)}</div> 503 <div class="cell-sub">${esc(a.artist)}</div></a>`; 504} 505function artistCell(ar) { 506 if (Suppress.artist(ar.artists_id)) return ""; // hidden by "Hide things I dislike" 507 const img = ar.album 508 ? imgTag(ar.name, ar.album, ARTIST.cell) + `<div class="no-photo" style="display:none"></div>` 509 : `<div class="no-photo"></div>`; 510 return `<a class="cell artist-cell" href="${artistUrl(ar.name)}"> 511 <div class="photo">${img}</div> 512 <div class="cell-name">${esc(ar.name)}</div></a>`; 513} 514// Artist LIST row (matches the iOS app): photo + name + inline favorite + add-to-playlist. 515// The name/photo are an <a> (the click-interceptor / hash routing navigates it); the action 516// buttons are siblings OUTSIDE that <a> so clicking them doesn't trigger navigation. 517function artistRow(ar) { 518 if (Suppress.artist(ar.artists_id)) return ""; // hidden by "Hide things I dislike" 519 const img = ar.album 520 ? imgTag(ar.name, ar.album, ARTIST.cell) + `<div class="no-photo" style="display:none"></div>` 521 : `<div class="no-photo"></div>`; 522 return `<div class="artist-row"> 523 <a class="ar-main" href="${artistUrl(ar.name)}"><div class="ar-photo">${img}</div><div class="ar-name">${esc(ar.name)}</div></a> 524 <div class="ar-actions">${rowPlay("artist", ar.artists_id)}${favBtn("artist", ar.artists_id)}<button class="addbtn" title="Add all songs to playlist" onclick="MAG.addArtist(event,${ar.artists_id})">${addIcon(false)}</button></div> 525 </div>`; 526} 527const artistList = (artists) => `<div class="artist-list">${artists.map(artistRow).join("")}</div>`; 528// Artist bio / album description: clamped to a few lines with an explicit "Show more / Show 529// less" toggle (matches the iOS app). wireDescToggles() hides the toggle if the text doesn't 530// actually overflow the clamp. 531function descBlock(text) { 532 // The catalog stores real newlines (unesc turns \n back into "\n"); a single <p> would 533 // collapse them into a run-on paragraph, so preserve them as <br> (a blank line = <br><br>). 534 if (!text) return "";
535 const html = esc(text).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>"); 536 return `<div class="desc-wrap"><p class="desc clamped">${html}</p><button class="desc-toggle" onclick="MAG.toggleDesc(this)" hidden>Show more</button></div>`; 537} 538MAG.toggleDesc = (btn) => { 539 const clamped = btn.parentNode.querySelector(".desc").classList.toggle("clamped"); 540 btn.textContent = clamped ? "Show more" : "Show less"; 541}; 542function wireDescToggles() { 543 document.querySelectorAll(".desc-wrap").forEach((w) => { 544 const p = w.querySelector(".desc"), b = w.querySelector(".desc-toggle"); 545 if (p && b && p.scrollHeight > p.clientHeight + 2) b.hidden = false; 546 }); 547} 548/* normalize a catalog song row into a playable track */ 549function trk(r, artist, album, albumId) { 550 return { 551 songId: r.song_id, title: r.name, mp3: r.mp3, duration: r.duration, 552 trackNo: r.track_no != null ? r.track_no : null, 553 artist: r.artist || artist, album: r.album || album, albumId: r.album_id || albumId, 554 artistId: r.artist_id != null ? r.artist_id : null, 555 }; 556} 557/* register a track list so its rows can drive the player; returns an id */ 558let _listSeq = 0; 559function registerList(tracks) { 560 const vis = tracks.filter((t) => !Suppress.song(t.songId)); // drop disliked-suppressed songs 561 const id = "L" + (++_listSeq); MAG.lists[id] = vis; return id; 562} 563function songsHTML(listId, withSub, navToAlbum) { 564 return `<div class="songs">` + 565 MAG.lists[listId].map((t, i) => songRow(t, i, withSub, listId, navToAlbum)).join("") + `</div>`; 566} 567// Small rounded-rectangle buttons for a song's artist + album (used in the sub line of 568// search / playlist rows). Each links to that artist/album; stopPropagation so it doesn't 569// also trigger the row's play action. In navToAlbum mode (search) the album chip deep-links 570// to the song on its album page (album + highlighted song) instead of just the album. 571function subChips(t, navToAlbum) { 572 let h = ""; 573 if (t.artist) { 574 h += `<a class="subchip" href="${artistUrl(t.artist)}" onclick="event.stopPropagation()"><i class="fa-solid fa-user"></i>${esc(t.artist)}</a>`; 575 } 576 if (t.album) { 577 const albumHref = navToAlbum && t.artist ? songUrl(t.artist, t.album, t.title) : albumUrl(t.artist, t.album); 578 h += t.artist // the album slug needs the artist name; without it, show a plain chip 579 ? `<a class="subchip" href="${albumHref}" onclick="event.stopPropagation()"><i class="fa-solid fa-compact-disc"></i>${esc(t.album)}</a>` 580 : `<span class="subchip"><i class="fa-solid fa-compact-disc"></i>${esc(t.album)}</span>`; 581 } 582 return h; 583} 584function songRow(t, i, withSub, listId, navToAlbum) { 585 const sub = withSub ? `<div class="song-sub">${subChips(t, navToAlbum)}</div>` : ""; 586 const cur = Player.current(); 587 const playing = cur && cur.songId === t.songId ? " playing" : ""; 588 const onPl = UserData.playlistedSongs().has(t.songId); 589 // In artwork mode (search / featured) show the album cover instead of a track number. 590 const lead = withSub 591 ? `<div class="song-art">${imgTag(t.artist, t.album, COVER.cell)}</div>` 592 : `<div class="track">${t.trackNo != null ? t.trackNo : i + 1}</div>`; 593 // navToAlbum (search): tapping the row (cover art or song name) opens the song's album page 594 // with that song highlighted, rather than playing it inline. The artist/album sub-chips and 595 // the action buttons stopPropagation so they keep their own behavior. 596 const rowClick = navToAlbum && t.artist && t.album 597 ? `MAG.go('${songUrl(t.artist, t.album, t.title)}')` 598 : `MAG.playFrom('${listId}',${i})`; 599 return `<div class="song-row${withSub ? " with-sub art" : ""}${navToAlbum ? " nav" : ""}${playing}" data-sid="${t.songId}" onclick="${rowClick}"> 600 ${lead} 601 <div class="song-name">${esc(t.title)}</div>${sub} 602 <div class="dur">${fmtDur(t.duration)}</div> 603 <div class="song-actions">${favBtn("song", t.songId)}${dislikeBtn("song", t.songId)}${dlSongBtn(t)}<button class="addbtn" title="${onPl ? "Remove from playlist" : "Add to playlist"}" onclick="MAG.addSong(event,${t.songId})">${addIcon(onPl)}</button></div></div>`; 604} 605const grid = (cells, cls = "") => `<div class="grid ${cls}">${cells.join("")}</div>`; 606 607/* ===================== routes ===================== */ 608function renderPopular(c) { 609 const rows = Q.genres().map((g) => { 610 // Drop disliked-genre albums; a row left with none (a disliked genre) disappears. 611 const albums = Q.popularInGenre(g.genre_id, 16).filter((a) => !Suppress.album(a.album_id)); 612 if (!albums.length) return ""; 613 return `<div class="row-head">${esc(g.name)}</div> 614 <div class="hrow">${albums.map(albumCell).join("")}</div>`; 615 }); 616 c.innerHTML = `<div class="page-title">Popular</div>${rows.join("")}`;
617} 618 619// Sort choices for the Albums/Artists browse pages, shown in a select beside the filter box. 620const SORT_OPTIONS = [ 621 { val: "popular", label: "Popular" }, 622 { val: "alphabetical", label: "Alphabetical" }, 623 { val: "recent", label: "Date" }, // "Date" = was "Recent" 624]; 625 626// A browse page with a live client-side filter box above a grid/list, mirroring the iOS 627// Filter field. o = {title, count, items, cell, place, name, wrap, sortable?}. 628// sortable = {current, reload(sortVal)->items} adds a "Sort:" select to the right of the filter. 629function filterable(c, o) { 630 MAG._flt = o; 631 const sub = o.count != null ? ` <span class="section-sub">${o.count}</span>` : ""; 632 // Two presentations of the same choice: the all-visible segmented control (wide) and a 633 // compact <select> (narrow/portrait, so it doesn't squeeze the filter box). CSS shows one. 634 const sortSel = o.sortable 635 ? `<div class="sortwrap">` + 636 `<div class="sortseg" role="group" aria-label="Sort">` + 637 SORT_OPTIONS.map((s) => `<button type="button" class="seg${s.val === o.sortable.current ? " on" : ""}" data-val="${s.val}" onclick="MAG.applySort('${s.val}')">${s.label}</button>`).join("") + 638 `</div>` + 639 `<select class="sortsel" aria-label="Sort" onchange="MAG.applySort(this.value)">` + 640 SORT_OPTIONS.map((s) => `<option value="${s.val}"${s.val === o.sortable.current ? " selected" : ""}>${s.label}</option>`).join("") + 641 `</select>` + 642 `</div>` 643 : ""; 644 c.innerHTML = 645 `<div class="page-title">${o.title}${sub}</div>` + 646 `<div class="filterbar">` + 647 `<input class="searchbox" placeholder="${o.place}" oninput="MAG.applyFilter(this.value)" autocomplete="off">` + 648 sortSel + 649 (o.extra || "") + 650 `</div>` + 651 `<div class="${o.wrap}" id="flt-host"></div>`; 652 MAG.applyFilter(""); 653} 654MAG.applyFilter = (s) => { 655 const o = MAG._flt; if (!o) return; 656 const ql = s.trim().toLowerCase(); 657 const items = ql ? o.items.filter((it) => o.name(it).toLowerCase().includes(ql)) : o.items; 658 const host = el("flt-host"); if (host) host.innerHTML = items.map(o.cell).join(""); 659 if (o.genre) refreshGenreOptions(ql); // pare the genre dropdown to genres present in the text matches 660}; 661// Rebuild the genre <select> to only the genres represented among items whose NAME matches 662// the current search text (over the whole catalog, not the genre-narrowed view). Empty text = 663// all genres. The active selection is always kept so it stays consistent with what's applied. 664function refreshGenreOptions(ql) { 665 const o = MAG._flt, gd = o && o.genre; if (!gd) return; 666 const sel = document.querySelector(".genresel"); if (!sel) return; 667 const cur = MAG[gd.stateKey] || ""; 668 let allowed = null; 669 if (ql) { 670 allowed = new Set(); 671 for (const it of gd.all) { 672 if (!o.name(it).toLowerCase().includes(ql)) continue; 673 const gs = gd.gByItem.get(gd.idOf(it)); if (gs) for (const g of gs) allowed.add(g); 674 } 675 if (cur) allowed.add(+cur); // keep the active genre selectable even if its name doesn't match 676 } 677 const genres = Q.genres().filter((g) => !Suppress.genre(g.genre_id)).filter((g) => !allowed || allowed.has(g.genre_id)); 678 sel.innerHTML = `<option value="">All Genres</option>` + 679 genres.map((g) => `<option value="${g.genre_id}"${String(g.genre_id) === String(cur) ? " selected" : ""}>${esc(g.name)}</option>`).join(""); 680} 681// item_id -> Set(genre_id) maps for the genre dropdown's text-aware narrowing. 682function artistGenresMap() { 683 const m = new Map(); 684 for (const r of q("select distinct al.artist_id aid, ga.genre_id gid from genres_albums ga join albums al on al.album_id=ga.album_id")) { 685 let s = m.get(r.aid); if (!s) m.set(r.aid, s = new Set()); s.add(r.gid); 686 } 687 return m; 688} 689function albumGenresMap() { 690 const m = new Map(); 691 for (const r of q("select album_id aid, genre_id gid from genres_albums")) { 692 let s = m.get(r.aid); if (!s) m.set(r.aid, s = new Set()); s.add(r.gid); 693 } 694 return m; 695} 696MAG.applySort = (val) => { 697 const o = MAG._flt; if (!o || !o.sortable) return; 698 o.sortable.current = val; 699 document.querySelectorAll(".sortseg .seg").forEach((b) => b.classList.toggle("on", b.dataset.val === val)); 700 const sel = document.querySelector(".filterbar .sortsel"); if (sel) sel.value = val; // keep the dropdown in sync 701 o.items = o.sortable.reload(val); // re-query in the new order 702 const box = document.querySelector(".filterbar .searchbox"); 703 MAG.applyFilter(box ? box.value : ""); // keep the current filter text applied 704}; 705 706// Genre filter <select> for the Artists/Albums pages (right of the sort control);
707// "Genres" (value "") = all genres. `handler` is the MAG.* fn called onchange. 708function genreSelectHTML(gid, handler) { 709 const genres = Q.genres().filter((g) => !Suppress.genre(g.genre_id)); 710 return `<select class="genresel" aria-label="Filter by genre" onchange="${handler}(this.value)">` + 711 `<option value="">All Genres</option>` + 712 genres.map((g) => `<option value="${g.genre_id}"${String(g.genre_id) === String(gid) ? " selected" : ""}>${esc(g.name)}</option>`).join("") + 713 `</select>`; 714} 715// All artists in the given sort order, optionally narrowed to one genre (gid; "" = all genres). 716function artistsInGenre(sort, gid) { 717 const all = Q.allArtists(sort); 718 if (!gid) return all; 719 const ids = new Set(Q.genreArtists(+gid).map((a) => a.artists_id)); 720 return all.filter((a) => ids.has(a.artists_id)); 721} 722function renderArtists(c) { 723 const sort = MAG._sortArtist || "popular"; 724 const gid = MAG._artistGenre || ""; 725 const items = artistsInGenre(sort, gid); 726 filterable(c, { title: "Artists", count: items.length, items, cell: artistRow, place: "Filter artists", name: (x) => x.name, wrap: "artist-list", 727 sortable: { current: sort, reload: (s) => { MAG._sortArtist = s; return artistsInGenre(s, MAG._artistGenre || ""); } }, 728 extra: genreSelectHTML(gid, "MAG.applyArtistGenre"), 729 genre: { all: Q.allArtists("alphabetical"), gByItem: artistGenresMap(), idOf: (a) => a.artists_id, stateKey: "_artistGenre" } }); 730} 731MAG.applyArtistGenre = (gid) => { 732 MAG._artistGenre = gid; 733 const o = MAG._flt; if (!o) return; 734 o.items = artistsInGenre(MAG._sortArtist || "popular", gid); 735 const sub = document.querySelector(".page-title .section-sub"); if (sub) sub.textContent = o.items.length; 736 const box = document.querySelector(".filterbar .searchbox"); 737 MAG.applyFilter(box ? box.value : ""); // keep any filter text applied to the new genre subset 738}; 739 740// All albums in the given sort order, optionally narrowed to one genre (gid; "" = all genres). 741function albumsInGenre(sort, gid) { 742 const all = Q.allAlbums(sort); 743 if (!gid) return all; 744 const ids = new Set(q("select album_id from genres_albums where genre_id=?", [+gid]).map((r) => r.album_id)); 745 return all.filter((a) => ids.has(a.album_id)); 746} 747function renderAlbums(c) { 748 const sort = MAG._sortAlbum || "popular"; 749 const gid = MAG._albumGenre || ""; 750 const items = albumsInGenre(sort, gid); 751 filterable(c, { title: "Albums", count: items.length, items, cell: albumCell, place: "Filter albums", name: (x) => x.name, wrap: "grid", 752 sortable: { current: sort, reload: (s) => { MAG._sortAlbum = s; return albumsInGenre(s, MAG._albumGenre || ""); } }, 753 extra: genreSelectHTML(gid, "MAG.applyAlbumGenre"), 754 genre: { all: Q.allAlbums("alphabetical"), gByItem: albumGenresMap(), idOf: (a) => a.album_id, stateKey: "_albumGenre" } }); 755} 756MAG.applyAlbumGenre = (gid) => { 757 MAG._albumGenre = gid; 758 const o = MAG._flt; if (!o) return; 759 o.items = albumsInGenre(MAG._sortAlbum || "popular", gid); 760 const sub = document.querySelector(".page-title .section-sub"); if (sub) sub.textContent = o.items.length; 761 const box = document.querySelector(".filterbar .searchbox"); 762 MAG.applyFilter(box ? box.value : ""); 763}; 764 765// FontAwesome 7 equivalents of the iOS app's per-genre SF Symbols (genreIcon in BrowseViews.swift) 766const GENRE_ICON = { 767 "Classical": "fa-piano", // ios: pianokeys 768 "New Age": "fa-moon", // ios: moon.stars 769 "Electronica": "fa-waveform", // ios: waveform 770 "World": "fa-globe", // ios: globe 771 "Ambient": "fa-wind", // ios: wind 772 "Jazz": "fa-music", // ios: music.quarternote.3 773 "Hip Hop": "fa-microphone", // ios: music.mic 774 "Alt Rock": "fa-guitar", // ios: guitars 775 "Electro Rock": "fa-bolt", // ios: bolt 776 "Hard Rock": "fa-fire", // ios: flame 777}; 778const genreIcon = (name) => GENRE_ICON[name] || "fa-music"; 779// "Play" + "Shuffle" buttons for a whole category (genre or tag), given a registered 780// track list id. Renders nothing if the list is empty (all disliked / no songs). 781function catPlay(lid) { 782 const n = (MAG.lists[lid] || []).length; 783 if (!n) return ""; 784 return `<div class="cat-play">` + 785 `<button class="playall" data-list="${lid}" onclick="MAG.playFrom('${lid}',0)">${playLabel(lid)}</button>` + 786 `<button class="playall ghost" onclick="MAG.shuffleList('${lid}')"><i class="fa-solid fa-shuffle"></i> Shuffle</button>` + 787 `<span class="cat-count">${n} song${n === 1 ? "" : "s"}</span></div>`; 788} 789// Quick-play button embedded in a category ROW (the genre/tag list). Plays the whole 790// category ranked; MAG.playCat stops the surrounding <a> from navigating. 791function rowPlay(kind, id) { 792 return `<button class="rowplay" title="Play" onclick="MAG.playCat(event,'${kind}',${id})"><i class="fa-solid fa-circle-play"></i></button>`; 793} 794function renderGenres(c) { 795 const g = Q.genres().filter((x) => !Suppress.genre(x.genre_id)); 796 const counts = liveCounts("select genre_id, album_id from genres_albums", "genre_id", "album"); 797 c.innerHTML = `<div class="page-title">Genres</div><div class="list">` + 798 g.map((x) => `<a href="${genreUrl(x.name)}"><span class="g-label"><i class="fa-solid ${genreIcon(x.name)}"></i>${esc(x.name)}</span><span class="cnt">${counts.get(x.genre_id) || 0}</span>${dislikeBtn("genre", x.genre_id)}${rowPlay("genre", x.genre_id)}</a>`).join("") + 799 `</div>`; 800} 801function renderGenre(c, id) { 802 const g = q1("select name from genres where genre_id=?", [id]); 803 const artists = Q.genreArtists(id); 804 const lid = registerList(Q.genreSongs(id).map((r) => trk(r))); 805 c.innerHTML = `<div class="page-title">${esc(g ? g.name : "Genre")}</div>` + 806 catPlay(lid) + 807 `<div class="section-sub" style="margin-top:-.8rem;margin-bottom:1rem">${artists.length} artists</div>` + 808 artistList(artists); 809} 810 811function renderTags(c) { 812 const t = Q.collections().filter((x) => !Suppress.tag(x.collections_id)); 813 const counts = liveCounts("select collection_id, album_id from collections_albums", "collection_id", "album"); 814 const cell = (x) => `<a href="${tagUrl(x.name)}"><span class="rlbl">${esc(x.name)}</span><span class="cnt">${counts.get(x.collections_id) || 0}</span>${rowPlay("tag", x.collections_id)}</a>`; 815 filterable(c, { title: "Tags", count: t.length, items: t, cell, place: "Filter tags", name: (x) => x.name, wrap: "list" }); 816} 817function renderTag(c, id) { 818 const t = q1("select name from collections where collections_id=?", [id]); 819 const albums = Q.collectionAlbums(id); 820 const lid = registerList(Q.collectionSongs(id).map((r) => trk(r))); 821 c.innerHTML = `<div class="page-title">${esc(t ? t.name : "Tag")}</div>` + catPlay(lid) + grid(albums.map(albumCell)); 822} 823 824function renderFeatured(c) { 825 const p = Q.featured().filter((x) => !Suppress.playlist(x.playlist_id)); 826 const counts = liveCounts("select ps.playlist_id, ps.song_id from playlist_songs ps join playlists p on p.playlist_id=ps.playlist_id where p.owner_id=0", "playlist_id", "song"); 827 c.innerHTML = `<div class="page-title">Featured</div>` + 828 `<div class="section-sub" style="margin-top:-.8rem;margin-bottom:1rem">Curated playlists</div>` + 829 `<div class="list">` + 830 p.map((x) => `<a href="${featuredUrl(x.name)}"><span class="rlbl">${esc(x.name)}</span><span class="cnt">${counts.get(x.playlist_id) || 0}</span>${rowPlay("featured", x.playlist_id)}</a>`).join("") + 831 `</div>`; 832} 833function renderFeaturedPlaylist(c, id) { 834 const p = q1("select name from playlists where playlist_id=?", [id]); 835 const songs = Q.playlistSongs(id); 836 const lid = registerList(songs.map((s) => trk(s))); 837 c.innerHTML = `<div class="page-title">${esc(p ? p.name : "Playlist")}</div>` + 838 `<div class="pl-actions"><button class="playall" data-list="${lid}" onclick="MAG.playFrom('${lid}',0)">${playLabel(lid)}</button></div>` + 839 songsHTML(lid, true); 840} 841 842// US state / territory postal codes â full names, so the artist "based in" line reads 843// "Berkeley, California, USA" rather than "Berkeley, CA, USA" (matches the old site's 844// state_name_short_to_long). Unknown/non-US values pass through unchanged. 845const US_STATES = { 846 AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas", CA: "California", 847 CO: "Colorado", CT: "Connecticut", DE: "Delaware", DC: "District of Columbia", 848 FL: "Florida", GA: "Georgia", HI: "Hawaii", ID: "Idaho", IL: "Illinois", 849 IN: "Indiana", IA: "Iowa", KS: "Kansas", KY: "Kentucky", LA: "Louisiana", 850 ME: "Maine", MD: "Maryland", MA: "Massachusetts", MI: "Michigan", MN: "Minnesota", 851 MS: "Mississippi", MO: "Missouri", MT: "Montana", NE: "Nebraska", NV: "Nevada", 852 NH: "New Hampshire", NJ: "New Jersey", NM: "New Mexico", NY: "New York", 853 NC: "North Carolina", ND: "North Dakota", OH: "Ohio", OK: "Oklahoma", OR: "Oregon", 854 PA: "Pennsylvania", RI: "Rhode Island", SC: "South Carolina", SD: "South Dakota", 855 TN: "Tennessee", TX: "Texas", UT: "Utah", VT: "Vermont", VA: "Virginia", 856 WA: "Washington", WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming", 857 PR: "Puerto Rico", GU: "Guam", VI: "U.S. Virgin Islands", AS: "American Samoa", 858 MP: "Northern Mariana Islands", 859}; 860const stateLong = (s) => { const t = (s || "").trim(); return t ? (US_STATES[t.toUpperCase()] || t) : ""; }; 861 862function renderArtist(c, id) { 863 const ar = Q.artist(id); 864 if (!ar) { c.innerHTML = `<div class="pad">Artist not found.</div>`; return; } 865 const albums = Q.artistAlbums(id).map((al) => ({ ...al, artist: ar.name })); 866 const aIds = Q.artistSongIds(id); 867 const alid = registerList(orderByIds(Q.songsByIds(aIds), "song_id", aIds).map((r) => trk(r))); 868 const photo = albums.length ? imgTag(ar.name, albums[0].name, ARTIST.detail, { eager: true }) : ""; 869 // Show the full artist bio (wave-2 data); fall back to the short genre tagline until it loads 870 // or when an artist has none. (Was description-only, so the rich bios never appeared.) 871 const blurb = ar.bio || ar.description || ""; 872 // Where the artist is based â restored from the catalog (city/state/country); the old site 873 // showed this. Empty parts are dropped, so it shows "Paris, France" or "Berkeley, CA, USA" 874 // and nothing at all when the catalog has no location (older dumps carry none). 875 const locParts = [ar.city, stateLong(ar.state), ar.country].map((s) =>
875 (s || "").trim()).filter(Boolean); 876 const locHtml = locParts.length 877 ? `<div class="artist-loc"><i class="fa-solid fa-location-dot"></i> ${esc(locParts.join(", "))}</div>` : ""; 878 const chips = 879 Q.artistGenres(id).map((g) => `<a class="chip" href="${genreUrl(g.name)}">${esc(g.name)}</a>`).join("") + 880 Q.artistTags(id).map((t) => `<a class="chip" href="${tagUrl(t.name)}">${esc(t.name)}</a>`).join(""); 881 c.innerHTML = 882 `<div class="detail-head"> 883 <div class="art round"${albums.length ? ` data-zoom="artist" data-artist="${esc(ar.name)}" data-album="${esc(albums[0].name)}"` : ""}>${photo}</div> 884 <div class="meta"> 885 <div class="kind">Artist</div><h1>${esc(ar.name)}</h1> 886 ${locHtml} 887 <div class="detail-actions"> 888 <button class="playall" data-list="${alid}" onclick="MAG.playFrom('${alid}',0)">${playLabel(alid)}</button> 889 ${favBtn("artist", id)} 890 ${dislikeBtn("artist", id)} 891 <button class="addbtn big" title="${aIds.some((sid) => UserData.playlistedSongs().has(sid)) ? "Remove songs from playlists" : "Add all songs to playlist"}" onclick="MAG.addArtist(event,${id})">${addIcon(aIds.some((sid) => UserData.playlistedSongs().has(sid)))}</button> 892 </div> 893 </div> 894 </div>` + 895 descBlock(blurb) + 896 (chips ? `<div class="chips">${chips}</div>` : "") + 897 `<div class="row-head">Albums</div>` + grid(albums.map(albumCell)) + 898 recArtistsHTML(id); 899} 900 901// "You might also like" for an artist page â other artists most often recommended across this 902// artist's albums, rendered as a horizontal scroll row (matching the album page; empty when none). 903function recArtistsHTML(id) { 904 const recs = Q.artistRecArtists(id); 905 return recs.length 906 ? `<br><div class="row-head">You might also like</div><div class="hrow rec-artists">${recs.map(artistCell).join("")}</div>` 907 : ""; 908} 909 910// "You might also like" for an album page â the recommendation engine's ranked sibling albums, 911// rendered as a horizontal scroll row (empty string when none). 912function recAlbumsHTML(id) { 913 const recs = Q.albumRecs(id); 914 return recs.length 915 ? `<br><div class="row-head">You might also like</div><div class="hrow">${recs.map(albumCell).join("")}</div>` 916 : ""; 917} 918 919function renderAlbum(c, id, songSlug) { 920 const al = Q.album(id); 921 if (!al) { c.innerHTML = `<div class="pad">Album not found.</div>`; return; } 922 const songs = Q.albumSongs(id); 923 const lid = registerList(songs.map((s) => trk(s, al.artist, al.name, al.album_id))); 924 const genres = Q.albumGenres(id); 925 const tags = Q.albumTags(id); 926 const year = al.release_date ? new Date(al.release_date * 1000).getFullYear() : ""; 927 const chips = 928 genres.map((g) => `<a class="chip" href="${genreUrl(g.name)}">${esc(g.name)}</a>`).join("") + 929 tags.map((t) => `<a class="chip" href="${tagUrl(t.name)}">${esc(t.name)}</a>`).join(""); 930 c.innerHTML = 931 `<div class="detail-head"> 932 <div class="art" data-zoom="cover" data-artist="${esc(al.artist)}" data-album="${esc(al.name)}"> 933 ${imgTag(al.artist, al.name, COVER.detail, { eager: true })}</div> 934 <div class="meta"> 935 <div class="kind">Album</div><h1>${esc(al.name)}</h1> 936 <div class="by">by <a href="${artistUrl(al.artist)}">${esc(al.artist)}</a></div> 937 ${year ? `<div class="yr">${year} · ${songs.length} tracks</div>` : ""} 938 <div class="chips">${chips}</div> 939 <div class="detail-actions"> 940 <button class="playall" data-list="${lid}" onclick="MAG.playFrom('${lid}',0)">${playLabel(lid)}</button> 941 ${favBtn("album", al.album_id)} 942 ${dislikeBtn("album", al.album_id)} 943 ${dlAlbumBtn(al.sku, true)} 944 <button class="addbtn big" title="${songs.some((s) => UserData.playlistedSongs().has(s.song_id)) ? "Remove album from playlists" : "Add album to playlist"}" onclick="MAG.addAlbum(event,${al.album_id})">${addIcon(songs.some((s) => UserData.playlistedSongs().has(s.song_id)))}</button> 945 </div> 946 </div> 947 </div>` + 948 descBlock(al.description) + 949 songsHTML(lid, false) + 950 recAlbumsHTML(id); 951 // Deep link to a specific song (#/album/<artist>/<album>/<song>): scroll that song into 952 // view and flash-highlight it (e.g. arriving from a search result). Does NOT auto-play â 953 // the user lands on the album with the song clearly marked and can press play. 954 if (songSlug) { 955 const hit = (MAG.lists[lid] || []).find((t) => slug(t.title) === songSlug); 956 if (hit) { 957 setTimeout(() => {
958 const row = c.querySelector(`.song-row[data-sid="${hit.songId}"]`); 959 if (row) { 960 row.scrollIntoView({ block: "center", behavior: "smooth" }); 961 row.classList.add("hl"); 962 setTimeout(() => row.classList.remove("hl"), 2200); 963 } 964 }, 0); 965 } 966 } 967} 968 969/* ---- search ---- */ 970let searchTimer = null; 971function renderSearch(c, query) { 972 query = (query || "").trim(); 973 c.innerHTML = 974 `<div class="page-title">Search</div> 975 <input class="searchbox" id="q" type="search" placeholder="Search artists, albums, songsâ¦" autocomplete="off"> 976 <div id="results"><div class="pad">Type to search the catalog.</div></div>`; 977 const input = el("q"); 978 input.value = query; 979 input.focus(); 980 // Reflect the query into the URL (#/search/<words>) so searches are linkable, but via 981 // replaceState â assigning location.hash would fire hashchange â re-render â lose focus. 982 const run = () => { 983 const v = input.value.trim(); 984 setHash(v ? "#/search/" + encodeURIComponent(v) : "#/search"); 985 doSearch(v); 986 }; 987 input.addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(run, 160); }); 988 if (query) doSearch(query); 989} 990// Compact search-result rows (small artist photo / album cover thumbnail + name), mirroring 991// the iOS search list. 992function searchArtistRow(ar) { 993 if (Suppress.artist(ar.artists_id)) return ""; 994 return `<a class="sresult" href="${artistUrl(ar.name)}"><span class="sr-thumb round">${imgTag(ar.name, ar.album, ARTIST.cell)}</span><span class="sr-name">${esc(ar.name)}</span></a>`; 995} 996function searchAlbumRow(al) { 997 if (Suppress.album(al.album_id)) return ""; 998 // Album name + thumbnail link to the album; the artist name is a chip linking to the 999 // artist (same style as the artist chip in the song rows). Separate links â the row is a 1000 // div, not a nested anchor. 1001 return `<div class="sresult">` + 1002 `<a class="sr-main" href="${albumUrl(al.artist, al.name)}"><span class="sr-thumb">${imgTag(al.artist, al.name, COVER.cell)}</span><span class="sr-name">${esc(al.name)}</span></a>` + 1003 `<a class="subchip" href="${artistUrl(al.artist)}"><i class="fa-solid fa-user"></i>${esc(al.artist)}</a>` + 1004 `</div>`; 1005} 1006function doSearch(t) { 1007 const r = el("results"); 1008 if (!r) return; 1009 if (t.length < 2) { r.innerHTML = `<div class="pad">Type to search the catalog.</div>`; return; } 1010 const artists = Q.searchArtists(t), albums = Q.searchAlbums(t), songs = Q.searchSongs(t); 1011 let html = ""; 1012 if (artists.length) html += `<div class="row-head">Artists</div><div class="sresults">` + artists.map(searchArtistRow).join("") + `</div>`; 1013 if (albums.length) html += `<div class="row-head">Albums</div><div class="sresults">` + albums.map(searchAlbumRow).join("") + `</div>`; 1014 if (songs.length) html += `<div class="row-head">Songs</div>` + songsHTML(registerList(songs.map((s) => trk(s))), true, true); 1015 r.innerHTML = html || `<div class="pad">No matches for â${esc(t)}â.</div>`; 1016} 1017 1018// Dedicated Songs page: a song-title search over the whole catalog (~24,000 tracks), 1019// mirroring the native iOS "Songs" browse section. Separate from the combined Search.
1020function renderSongs(c, query) { 1021 query = (query || "").trim(); 1022 c.innerHTML = 1023 `<div class="page-title">Songs</div> 1024 <input class="searchbox" id="sq" type="search" placeholder="Search songs by titleâ¦" autocomplete="off"> 1025 <div id="songresults"><div class="pad">There are ~24,000 tracks. Type to search by title.</div></div>`; 1026 const input = el("sq"); 1027 input.value = query; 1028 input.focus(); 1029 const run = () => { 1030 const v = input.value.trim(); 1031 setHash(v ? "#/songs/" + encodeURIComponent(v) : "#/songs"); 1032 doSongSearch(v); 1033 }; 1034 input.addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(run, 160); }); 1035 if (query) doSongSearch(query); 1036} 1037function doSongSearch(t) { 1038 const r = el("songresults"); 1039 if (!r) return; 1040 if (t.length < 2) { r.innerHTML = `<div class="pad">There are ~24,000 tracks. Type to search by title.</div>`; return; } 1041 const songs = Q.searchSongs(t); 1042 r.innerHTML = songs.length 1043 ? songsHTML(registerList(songs.map((s) => trk(s))), true, true) 1044 : `<div class="pad">No songs match â${esc(t)}â.</div>`; 1045} 1046 1047/* ---- full-screen cover zoom ---- */ 1048function openZoomImage(artist, album, file) { 1049 const d = document.createElement("div"); 1050 d.className = "zoom"; 1051 d.innerHTML = imgTag(artist, album, file, { eager: true }); 1052 d.addEventListener("click", () => d.remove()); 1053 document.body.appendChild(d); 1054} 1055// Delegated: click any [data-zoom] artwork â full-size overlay (works for all names, 1056// incl. apostrophes/ampersands, since the values come from safe data attributes). 1057document.addEventListener("click", (e) => { 1058 const z = e.target.closest("[data-zoom]"); 1059 if (!z) return; 1060 const artist = z.dataset.artist, album = z.dataset.album; 1061 if (!artist || !album) return; 1062 const file = z.dataset.zoom === "artist" ? "artist_840.jpg" : COVER.zoom; 1063 openZoomImage(artist, album, file); 1064}); 1065 1066/* ===================== audio / player ===================== */ 1067// Ordered stem suffixes to try for the current track, best first. Members on "High" get 1068// the 192k Opus (<stem>_hi.opus); if it isn't available â album not yet encoded, or a 1069// browser that can't play Opus â it falls straight through to the Normal member stream 1070// (96k Opus <stem>.opus / 185k AAC <stem>.m4a), then the free announcement file 1071// (<stem>_spoken.*). Non-members only get the free file. The player walks this list on 1072// <audio> error, so a missing tier steps down safely (see the error handler). 1073// Opus-first for everyone; fall back to AAC only if playback actually fails. We deliberately 1074// do NOT gate on canPlayType('audio/ogg; codecs=opus') â Safari reports "" for Ogg Opus even 1075// on versions that can play it (the same false-negative we hit with AVFoundation on the native 1076// apps), so a capability sniff wrongly demotes Safari to AAC. Instead every browser tries Opus; 1077// if a track can't decode, the <audio> error handler walks down to AAC AND sets this flag, so 1078// the rest of the session skips Opus (no wasted per-track attempts on Opus-less browsers). 1079let OPUS_DISABLED = false; 1080// Ordered fallback chain of {s:suffix, e:extension}. The <audio> error handler walks it 1081// on failure, so a missing tier (e.g. an album not yet Opus-encoded) steps down safely: 1082// opus-normal -> aac-normal -> free advert. Non-members only ever get the free advert AAC; 1083// the free _spoken file is already a small 51k AAC, so it stays AAC (Opus would be bigger). 1084function streamChain() { 1085 const member = UserData.member && UserData.member.loggedIn; 1086 const opus = !OPUS_DISABLED; 1087 if (!member) { 1088 // Non-members: 40k Opus advert (equal quality to the 51k AAC, smaller) first; fall back to 1089 // the AAC advert if Opus won't play or the album isn't encoded yet. 1090 return opus 1091 ? [{ s: "_spoken", e: ".opus" }, { s: "_spoken", e: ".m4a" }] 1092 : [{ s: "_spoken", e: ".m4a" }]; 1093 } 1094 const chain = []; 1095 // "High" tier (stored value still "lossless") = 192k Opus (near-transparent). If that file 1096 // isn't there for this album it falls to Normal Opus (96k), then AAC, then the advert. If the 1097 // browser can't decode Opus at all, every Opus entry fails and the error handler disables 1098 // Opus for the session (see makeEl), so later tracks skip straight to AAC. 1099 if (Player.quality === "lossless" && opus) chain.push({ s: "_hi", e: ".opus" }); // 192k Opus â High 1100 if (opus) chain.push({ s: "", e: ".opus" }); // 96k Opus member stream (Normal) 1101 chain.push({ s: "", e: ".m4a" }); // 185k AAC (Opus-less browsers / missing opus) 1102 if (opus) chain.push({ s: "_spoken", e: ".opus" }); // 40k Opus advert (last resort) 1103 chain.push({ s: "_spoken", e: ".m4a" }); // AAC advert (final fallback) 1104 return chain; 1105} 1106// Opaque per-session token the server hands to real clients at startup. It is 1107// appended to stream URLs (as ?k=) purely so play logging can tell genuine 1108// playback from a crawler fetching a /music URL directly â a bot never asks 1109// for one. Best-effort: if it's missing, audio streams exactly as before, the 1110// play just isn't counted. Never gate playback on it. 1111let PLAY_TOKEN = ""; 1112async function fetchPlayToken() { 1113 try { 1114 const r = await fetch("/api/playtoken", { cache: "no-store" }); 1115 if (!r.ok) return; 1116 const j = await r.json(); 1117 if (j && j.k) PLAY_TOKEN = j.k; 1118 } catch (e) { /* ignore â logging must never break the player */ } 1119} 1120 1121// Build a media URL for a specific chain entry {s,e}. 1122function mediaURL(t, entry) { 1123 const stem = (t.mp3 || "").replace(/\.mp3$/i, ""); 1124 const file = stem + entry.s + entry.e; 1125 return MEDIA_BASE + encodeURIComponent(t.artist) + "/" + encodeURIComponent(t.album) + "/" + encodeURIComponent(file)
1126 + (PLAY_TOKEN ? "?k=" + PLAY_TOKEN : ""); 1127} 1128function streamURL(t, idx) { 1129 const chain = streamChain(); 1130 return mediaURL(t, chain[Math.min(idx || 0, chain.length - 1)]); 1131} 1132// Human label of the audio the player is CURRENTLY streaming, read from the live chain entry 1133// so it updates as the fallback steps down: "Opus · 192 kbps" / "Opus · 96 kbps" / 1134// "AAC · 185 kbps", or "⦠· preview" for the free advert. Shown under the title in Now Playing. 1135function streamFormatLabel() { 1136 const c = Player._chain && Player._chain[Player._fallback]; 1137 if (!c) return ""; 1138 const codec = c.e === ".opus" ? "Opus" : "AAC"; 1139 if (c.s === "_spoken") return codec + " · preview"; 1140 const kbps = c.s === "_hi" ? 192 : (c.e === ".opus" ? 96 : 185); 1141 return codec + " · " + kbps + " kbps"; 1142} 1143 1144const REPEAT_CACHE_MAX = 30; // max tracks held in the in-memory <audio> cache while repeat is on 1145const Player = { 1146 audio: null, // the currently-ACTIVE <audio> element (drives the UI / transport) 1147 primary: null, // single reusable element used when repeat is OFF 1148 pool: null, // streamURL -> a retained, fully-buffered <audio> (the repeat cache) 1149 poolOrder: null, // LRU order of pool keys 1150 queue: [], index: -1, 1151 _chain: [], // fallback-chain snapshot for the CURRENT track â stable across its 1152 // per-track error walk even if OPUS_DISABLED flips mid-track 1153 shuffle: localStorage.getItem("shuffle.enabled") === "1", 1154 repeat: localStorage.getItem("magnatune.repeat") === "1", 1155 volume: localStorage.getItem("magnatune.volume") == null ? 1 : +localStorage.getItem("magnatune.volume"), 1156 quality: localStorage.getItem("magnatune.quality") === "lossless" ? "lossless" : "normal", 1157 _fallback: 0, 1158 // Build a fully-wired <audio>. Primary + every pool element share the same listeners, 1159 // but each only drives the UI / transport while it is the ACTIVE element â so the 1160 // background-preloading pool elements never fire spurious ended/error transitions. 1161 makeEl() { 1162 const a = new Audio(); 1163 a.preload = "auto"; // buffer the whole (small) file so repeat loops replay from memory 1164 a.volume = this.volume; 1165 a.setAttribute("x-webkit-airplay", "allow"); 1166 a.addEventListener("timeupdate", () => { if (a === this.audio) playerTick(); }); 1167 a.addEventListener("durationchange", () => { if (a === this.audio) playerTick(); }); 1168 a.addEventListener("play", () => { if (a === this.audio) { updatePlayPauseUI(); if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "playing"; } }); 1169 a.addEventListener("pause", () => { if (a === this.audio) { updatePlayPauseUI(); if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "paused"; } }); 1170 a.addEventListener("ended", () => { if (a === this.audio) this.next(); }); 1171 a.addEventListener("error", () => { 1172 if (a !== this.audio) return; // ignore errors from background-preloading pool elements 1173 // Step down this track's (snapshotted) fallback chain: High Opus -> Normal Opus -> AAC -> 1174 // advert. Crossing from the last Opus entry to a non-Opus one means every Opus stream 1175 // failed â this browser can't decode Opus, so disable it for the rest of the session 1176 // (later tracks then skip straight to AAC â no wasted attempts). 1177 const t = this.current(); 1178 const chain = (this._chain && this._chain.length) ? this._chain : streamChain(); 1179 const failed = this._fallback, next = failed + 1; 1180 if (t && next < chain.length) { 1181 if (chain[failed].e === ".opus" && chain[next].e !== ".opus") OPUS_DISABLED = true; 1182 this._fallback = next; 1183 a.src = mediaURL(t, chain[next]); 1184 a.play().catch(() => {}); 1185 if (MAG.nowPlayingOpen) { const fe = el("np-format"); if (fe) fe.textContent = streamFormatLabel(); } 1186 } else console.warn("audio error", a.error && a.error.code); 1187 }); 1188 return a; 1189 }, 1190 init() { 1191 this.pool = new Map(); 1192 this.poolOrder = []; 1193 this.primary = this.makeEl(); 1194 this.audio = this.primary; 1195 setupMediaSession(); 1196 }, 1197 current() { return this.index >= 0 ? this.queue[this.index] : null; }, 1198 eachEl(fn) { if (this.primary) fn(this.primary); if (this.pool) this.pool.forEach(fn); }, 1199 play(tracks, startAt = 0) { 1200 if (!tracks || !tracks.length) return; 1201 let qq = tracks.slice(), start = startAt; 1202 if (this.shuffle) { 1203 const chosen = qq[startAt]; 1204 qq = qq.filter((_, i) => i !== startAt); 1205 for (let i = qq.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [qq[i], qq[j]] = [qq[j], qq[i]]; } 1206 qq.unshift(chosen); start = 0; 1207 } 1208 this.queue = qq; 1209 this.resetCache(); // a NEW thing was pressed Play on â drop the old repeat cache 1210 this.playAt(start); 1211 if (this.repeat) this.warmCache(); // pre-buffer the whole queue so the loop needs no network 1212 }, 1213 // Pick the <audio> for track t: a retained, pre-buffered pool element when repeat is on 1214 // (so loops replay from memory with NO network), else the single primary element. 1215 elementFor(t) { 1216 if (!this.repeat) return this.primary; 1217 const url = streamURL(t, 0); 1218 let a = this.pool.get(url); 1219 if (!a) { a = this.makeEl(); a.src = url; this.pool.set(url, a); this.poolOrder.push(url); this.trimPool(); }
1220 else { const k = this.poolOrder.indexOf(url); if (k >= 0) { this.poolOrder.splice(k, 1); this.poolOrder.push(url); } } 1221 return a; 1222 }, 1223 playAt(i) { 1224 if (i < 0 || i >= this.queue.length) return; 1225 this.index = i; 1226 this._fallback = 0; 1227 this._chain = streamChain(); // snapshot for this track's error walk 1228 const t = this.queue[i]; 1229 UserData.recordPlay(t.songId); // add to "Recently Played" as it starts 1230 const a = this.elementFor(t); 1231 if (a === this.primary) a.src = mediaURL(t, this._chain[0]); // primary: (re)point at this track 1232 if (this.audio && this.audio !== a) { try { this.audio.pause(); } catch (e) {} } 1233 this.audio = a; 1234 a.volume = this.volume; 1235 try { a.currentTime = 0; } catch (e) {} // pool element: restart its buffer (no refetch) 1236 a.play().catch((e) => console.warn("playback blocked:", e.message)); 1237 renderMiniPlayer(); setMediaMetadata(t); highlightPlayingRow(); 1238 syncSongInUrl(t); // reflect the song in the URL when viewing its album page 1239 if (MAG.nowPlayingOpen) renderNowPlaying(); 1240 }, 1241 toggle() { this.audio.paused ? this.audio.play().catch(() => {}) : this.audio.pause(); }, 1242 next() { 1243 if (this.index < this.queue.length - 1) this.playAt(this.index + 1); 1244 else if (this.repeat && this.queue.length) this.playAt(0); // loop the whole queue forever 1245 else this.audio.pause(); 1246 }, 1247 prev() { if (this.audio.currentTime > 3 || this.index <= 0) this.audio.currentTime = 0; else this.playAt(this.index - 1); }, 1248 seekFrac(f) { if (isFinite(this.audio.duration)) this.audio.currentTime = f * this.audio.duration; }, 1249 setShuffle(on) { this.shuffle = on; localStorage.setItem("shuffle.enabled", on ? "1" : "0"); updateShuffleUI(); }, 1250 setRepeat(on) { 1251 this.repeat = on; localStorage.setItem("magnatune.repeat", on ? "1" : "0"); updateRepeatUI(); 1252 if (on) this.warmCache(); else this.resetCache(); 1253 }, 1254 // Pre-buffer every queue track (capped) into retained <audio> elements, so that once the 1255 // queue loops the audio comes entirely from memory â no further network fetches. 1256 warmCache() { 1257 if (!this.repeat || !this.queue.length) return; 1258 this.queue.slice(0, REPEAT_CACHE_MAX).forEach((t) => { 1259 const url = streamURL(t, 0); 1260 if (!this.pool.has(url)) { const a = this.makeEl(); a.src = url; this.pool.set(url, a); this.poolOrder.push(url); } 1261 }); 1262 this.trimPool(); 1263 }, 1264 trimPool() { 1265 while (this.poolOrder.length > REPEAT_CACHE_MAX) { 1266 const url = this.poolOrder.shift(); 1267 const a = this.pool.get(url); 1268 if (a && a !== this.audio) { try { a.pause(); a.removeAttribute("src"); a.load(); } catch (e) {} this.pool.delete(url); } 1269 else if (a) { this.poolOrder.push(url); break; } // never evict the active element 1270 } 1271 }, 1272 resetCache() { 1273 if (!this.pool) return; 1274 this.pool.forEach((a) => { if (a !== this.audio) { try { a.pause(); a.removeAttribute("src"); a.load(); } catch (e) {} } }); 1275 this.pool = new Map(); this.poolOrder = []; 1276 if (this.audio && this.audio !== this.primary) { // keep whatever is currently playing alive 1277 const cur = this.current(); if (cur) { const u = streamURL(cur, 0); this.pool.set(u, this.audio); this.poolOrder.push(u); } 1278 } 1279 }, 1280 setVolume(v) { this.volume = Math.max(0, Math.min(1, v)); this.eachEl((a) => { a.volume = this.volume; }); localStorage.setItem("magnatune.volume", this.volume); }, 1281 setQuality(q) { 1282 this.quality = q === "lossless" ? "lossless" : "normal"; 1283 localStorage.setItem("magnatune.quality", this.quality); 1284 this.resetCache(); // tier change â cached stream URLs are stale 1285 // Re-load the current track at the new tier, keeping playback position. 1286 const t = this.current(); 1287 if (!t || !this.audio || !this.audio.src) return; 1288 const a = this.audio, pos = a.currentTime || 0, wasPlaying = !a.paused; 1289 this._fallback = 0; 1290 const onMeta = () => { try { a.currentTime = pos; } catch (e) {} if (wasPlaying) a.play().catch(() => {}); a.removeEventListener("loadedmetadata", onMeta); }; 1291 a.addEventListener("loadedmetadata", onMeta); 1292 a.src = streamURL(t, 0); 1293 a.load(); 1294 if (this.repeat) this.warmCache(); 1295 }, 1296}; 1297MAG.playFrom = (listId, i) => { const ts = MAG.lists[listId]; if (ts) Player.play(ts, i); }; 1298// Shuffle-play a whole registered list, regardless of the global shuffle toggle 1299// (pre-shuffles a copy so "Shuffle" always shuffles and the user's setting is untouched). 1300MAG.shuffleList = (listId) => { 1301 const ts = MAG.lists[listId]; if (!ts || !ts.length) return; 1302 const a = ts.slice(); 1303 for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } 1304 Player.play(a, 0); 1305}; 1306// Quick-play a category (genre/tag) straight from its list row, ranked by popularity. 1307MAG.playCat = (e, kind, id) => { 1308 e.preventDefault(); e.stopPropagation(); 1309 if (kind === "artist") { MAG.playArtist(id); return; } // all of an artist's songs, ranked 1310 const rows = kind === "genre" ? Q.genreSongs(id) : kind === "featured" ? Q.playlistSongs(id) : Q.collectionSongs(id); 1311 const ts = MAG.lists[registerList(rows.map((r) => trk(r)))]; 1312 if (ts && ts.length) Player.play(ts, 0); 1313}; 1314 1315/* ---- mini-player ---- */ 1316function renderMiniPlayer() { 1317 const t = Player.current(), mp = el("miniplayer"); 1318 if (!mp) return; 1319 if (!t) { mp.hidden = true; return; } 1320 mp.hidden = false;
1321 el("mp-cover").innerHTML = imgTag(t.artist, t.album, COVER.cell, { eager: true }); 1322 el("mp-title").textContent = t.title; 1323 el("mp-artist").textContent = t.artist; 1324 updatePlayPauseUI(); updateShuffleUI(); updateRepeatUI(); 1325} 1326function playerTick() { 1327 const a = Player.audio, seek = el("mp-seek"); 1328 if (!seek) return; 1329 const frac = (a.duration && isFinite(a.duration)) ? a.currentTime / a.duration : 0; 1330 seek.value = Math.round(frac * 1000); 1331 el("mp-cur").textContent = fmtDur(a.currentTime); 1332 el("mp-dur").textContent = isFinite(a.duration) ? fmtDur(a.duration) : fmtDur((Player.current() || {}).duration || 0); 1333 if (MAG.nowPlayingOpen) npTick(); 1334} 1335function updatePlayPauseUI() { 1336 const icon = `<i class="fa-solid ${Player.audio.paused ? "fa-play" : "fa-pause"}"></i>`; 1337 const b = el("mp-playpause"); if (b) b.innerHTML = icon; 1338 const nb = el("np-playpause"); if (nb) nb.innerHTML = icon; 1339 syncPlayButtons(); 1340} 1341// A detail "Play" button reads "Now Playing" while a track from its list (album / artist / 1342// playlist) is the current, actively-playing track. 1343function playLabel(lid) { 1344 const cur = Player.current(); 1345 const on = cur && !Player.audio.paused && (MAG.lists[lid] || []).some((t) => t.songId === cur.songId); 1346 return on ? `<i class="fa-solid fa-volume-high"></i> Now Playing` : "â¶ Play"; 1347} 1348function syncPlayButtons() { 1349 document.querySelectorAll("button.playall[data-list]").forEach((b) => { b.innerHTML = playLabel(b.dataset.list); }); 1350} 1351function updateShuffleUI() { 1352 ["mp-shuffle", "np-shuffle"].forEach((id) => { const b = el(id); if (b) b.classList.toggle("on", Player.shuffle); }); 1353} 1354function updateRepeatUI() { 1355 ["mp-repeat", "np-repeat"].forEach((id) => { const b = el(id); if (b) b.classList.toggle("on", Player.repeat); }); 1356} 1357function highlightPlayingRow() { 1358 const t = Player.current(); if (!t) return; 1359 document.querySelectorAll(".song-row.playing").forEach((r) => r.classList.remove("playing")); 1360 document.querySelectorAll(`.song-row[data-sid="${t.songId}"]`).forEach((r) => r.classList.add("playing")); 1361} 1362function wirePlayer() { 1363 el("mp-playpause").onclick = () => Player.toggle(); 1364 el("mp-prev").onclick = () => Player.prev(); 1365 el("mp-next").onclick = () => Player.next(); 1366 el("mp-shuffle").onclick = () => Player.setShuffle(!Player.shuffle); 1367 el("mp-repeat").onclick = () => Player.setRepeat(!Player.repeat); 1368 el("mp-dislike").onclick = () => MAG.dislikeAndSkip(); 1369 el("mp-seek").addEventListener("input", (e) => Player.seekFrac(e.target.value / 1000)); 1370 el("mp-meta").onclick = () => openNowPlaying();
1371 // Right side: master volume + (Safari only) AirPlay. On wide screens the volume slider sits 1372 // inline; on narrow (phone) screens it collapses to a button that opens a popover slider â 1373 // matching the iOS app, which shows an inline slider on desktop and a popover on iPhone. 1374 const vol = Math.round(Player.volume * 100); 1375 const airplay = window.WebKitPlaybackTargetAvailabilityEvent 1376 ? `<button id="mp-airplay" title="AirPlay"><i class="fa-solid fa-tower-broadcast"></i><span class="mp-rlabel"> AirPlay</span></button>` : ""; 1377 el("mp-right").innerHTML = 1378 `<button id="mp-vol-btn" class="mp-volbtn" title="Volume"><i class="fa-solid fa-volume-high"></i></button>` + 1379 `<div class="vol-ctl"><i class="fa-solid fa-volume-high"></i>` + 1380 `<input type="range" id="mp-vol" min="0" max="100" value="${vol}" title="Volume"></div>${airplay}` + 1381 `<div class="mp-vol-pop" id="mp-vol-pop" hidden><i class="fa-solid fa-volume-high"></i><input type="range" id="mp-vol2" min="0" max="100" value="${vol}" title="Volume"></div>`; 1382 const setVol = (v) => { Player.setVolume(v / 100); const a = el("mp-vol"), b = el("mp-vol2"); if (a) a.value = v; if (b) b.value = v; }; 1383 el("mp-vol").addEventListener("input", (e) => setVol(+e.target.value)); 1384 el("mp-vol2").addEventListener("input", (e) => setVol(+e.target.value)); 1385 el("mp-vol-btn").onclick = (e) => { e.stopPropagation(); const p = el("mp-vol-pop"); p.hidden = !p.hidden; }; 1386 document.addEventListener("click", (e) => { // tap-away closes the popover 1387 const p = el("mp-vol-pop"); 1388 if (p && !p.hidden && !e.target.closest("#mp-vol-pop") && !e.target.closest("#mp-vol-btn")) p.hidden = true; 1389 }); 1390 if (window.WebKitPlaybackTargetAvailabilityEvent) { 1391 el("mp-airplay").onclick = () => { try { Player.audio.webkitShowPlaybackTargetPicker(); } catch (e) {} }; 1392 } 1393} 1394 1395/* ---- Now Playing overlay ---- */ 1396function openNowPlaying() { if (Player.current()) { MAG.nowPlayingOpen = true; renderNowPlaying(); } } 1397function closeNowPlaying() { MAG.nowPlayingOpen = false; const o = el("nowplaying"); if (o) o.remove(); } 1398window.closeNowPlaying = closeNowPlaying; 1399function renderNowPlaying() { 1400 const t = Player.current(); if (!t) { closeNowPlaying(); return; } 1401 let o = el("nowplaying"); 1402 if (!o) { o = document.createElement("div"); o.id = "nowplaying"; o.className = "nowplaying"; document.body.appendChild(o); } 1403 o.innerHTML = 1404 `<button class="np-close" onclick="closeNowPlaying()" title="Close"><i class="fa-solid fa-chevron-down"></i></button> 1405 <div class="np-art" data-zoom="cover" data-artist="${esc(t.artist)}" data-album="${esc(t.album)}">${imgTag(t.artist, t.album, COVER.detail, { eager: true })}</div> 1406 <a class="np-title" href="${albumUrl(t.artist, t.album)}" onclick="closeNowPlaying()">${esc(t.title)}</a>
1407 <a class="np-artist" href="${artistUrl(t.artist)}" onclick="closeNowPlaying()">${esc(t.artist)}</a> 1408 <a class="np-album" href="${albumUrl(t.artist, t.album)}" onclick="closeNowPlaying()">${esc(t.album)}</a> 1409 <div class="np-format" id="np-format">${streamFormatLabel()}</div> 1410 <div class="np-seekrow"><span id="np-cur" class="mp-time">0:00</span> 1411 <input type="range" id="np-seek" min="0" max="1000" value="0"> 1412 <span id="np-dur" class="mp-time">0:00</span></div> 1413 <div class="np-buttons"> 1414 <button id="np-shuffle" title="Shuffle"><i class="fa-solid fa-shuffle"></i></button> 1415 <button id="np-prev" title="Previous"><i class="fa-solid fa-backward-step"></i></button> 1416 <button id="np-playpause" class="primary"><i class="fa-solid ${Player.audio.paused ? "fa-play" : "fa-pause"}"></i></button> 1417 <button id="np-next" title="Next"><i class="fa-solid fa-forward-step"></i></button> 1418 <button id="np-dislike" title="Dislike & skip"><i class="fa-solid fa-heart-crack"></i></button> 1419 <button id="np-repeat" title="Repeat"><i class="fa-solid fa-repeat"></i></button> 1420 <button id="np-queue-btn" title="Current playlist"><i class="fa-solid fa-list-ol"></i></button> 1421 </div>` + 1422 npQueueHTML(); 1423 el("np-prev").onclick = () => Player.prev(); 1424 el("np-next").onclick = () => Player.next(); 1425 el("np-playpause").onclick = () => Player.toggle(); 1426 el("np-shuffle").onclick = () => Player.setShuffle(!Player.shuffle); 1427 el("np-repeat").onclick = () => Player.setRepeat(!Player.repeat); 1428 el("np-dislike").onclick = () => MAG.dislikeAndSkip(); 1429 el("np-seek").addEventListener("input", (e) => Player.seekFrac(e.target.value / 1000)); 1430 const qb = el("np-queue-btn"); 1431 if (qb) qb.onclick = () => { MAG.queueOpen = !MAG.queueOpen; qb.classList.toggle("on", MAG.queueOpen); const p = el("np-queue"); if (p) { p.classList.toggle("open", MAG.queueOpen); if (MAG.queueOpen) { const cur = p.querySelector(".npq-row.cur"); if (cur) cur.scrollIntoView({ block: "nearest" }); } } }; 1432 if (MAG.queueOpen) { const qb2 = el("np-queue-btn"); if (qb2) qb2.classList.add("on"); } 1433 updateShuffleUI(); updateRepeatUI(); npTick(); 1434} 1435// "Current playlist" â the live play queue, shown inside Now Playing. Click a row to jump to it. 1436function npQueueHTML() { 1437 const qq = Player.queue || []; 1438 if (!qq.length) return ""; 1439 const rows = qq.map((t, i) => 1440 `<div class="npq-row${i === Player.index ? " cur" : ""}" onclick="MAG.queueJump(${i})"> 1441 <span class="npq-idx">${i === Player.index ? '<i class="fa-solid fa-volume-high"></i>' : (i + 1)}</span> 1442 <span class="npq-meta"><span class="npq-title">${esc(t.title)}</span><span class="npq-art">${esc(t.artist)}</span></span> 1443 <span class="npq-dur">${fmtDur(t.duration || 0)}</span> 1444 </div>`).join(""); 1445 return `<div class="np-queue${MAG.queueOpen ? " open" : ""}" id="np-queue"> 1446 <div class="npq-head">Current playlist · ${qq.length} track${qq.length === 1 ? "" : "s"}</div>${rows}</div>`; 1447} 1448MAG.queueJump = (i) => { Player.playAt(i); renderNowPlaying(); }; 1449function npTick() { 1450 const a = Player.audio, s = el("np-seek"); if (!s) return; 1451 const frac = (a.duration && isFinite(a.duration)) ? a.currentTime / a.duration : 0; 1452 s.value = Math.round(frac * 1000); 1453 el("np-cur").textContent = fmtDur(a.currentTime); 1454 el("np-dur").textContent = isFinite(a.duration) ? fmtDur(a.duration) : fmtDur((Player.current() || {}).duration || 0); 1455} 1456 1457/* ---- Media Session (OS now-playing / media keys / lock screen) ---- */ 1458function setupMediaSession() { 1459 if (!("mediaSession" in navigator)) return; 1460 navigator.mediaSession.setActionHandler("play", () => Player.toggle()); 1461 navigator.mediaSession.setActionHandler("pause", () => Player.toggle()); 1462 navigator.mediaSession.setActionHandler("previoustrack", () => Player.prev()); 1463 navigator.mediaSession.setActionHandler("nexttrack", () => Player.next()); 1464} 1465 1466/* ---- Keyboard shortcuts ---- */ 1467// Space toggles play/pause, the way every desktop music player does. Ignored while the 1468// user is typing (search/login boxes) or focused on an actionable control (so Space still
1469// activates buttons/links), and only when something is loaded â otherwise Space scrolls. 1470function setupKeyboard() { 1471 document.addEventListener("keydown", (e) => { 1472 if (e.key !== " " && e.code !== "Space") return; 1473 if (e.metaKey || e.ctrlKey || e.altKey) return; 1474 const t = e.target; 1475 if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT|BUTTON|A)$/.test(t.tagName))) return; 1476 if (!Player.current()) return; 1477 e.preventDefault(); // stop the page from scrolling 1478 Player.toggle(); 1479 }); 1480} 1481function setMediaMetadata(t) { 1482 if (!("mediaSession" in navigator)) return; 1483 const art = new URL(imgURL(t.artist, t.album, "cover_300.webp"), location.href).href; 1484 navigator.mediaSession.metadata = new MediaMetadata({ 1485 title: t.title, artist: t.artist, album: t.album, 1486 artwork: [{ src: art, sizes: "300x300", type: "image/webp" }], 1487 }); 1488} 1489 1490/* ===================== user data (localStorage) ===================== */ 1491const RECENT_CAP = 100; // "Recently Played" keeps at most this many songs 1492const UserData = { 1493 favs: null, dislikes: null, hideDislikes: true, playlists: null, recentlyPlayed: null, member: null, _seq: 1, _plSet: null, 1494 dlAlbum: "vbr", dlSong: "mp3", theme: "system", 1495 _read(k, d) { try { const v = JSON.parse(localStorage.getItem(k)); return v == null ? d : v; } catch (e) { return d; } }, 1496 init() { 1497 this.favs = this._read("magnatune.favorites", { song: [], album: [], artist: [] }); 1498 for (const k of ["song", "album", "artist"]) if (!Array.isArray(this.favs[k])) this.favs[k] = []; 1499 this.dislikes = this._read("magnatune.dislikes", { song: [], album: [], artist: [], genre: [] }); 1500 for (const k of ["song", "album", "artist", "genre"]) if (!Array.isArray(this.dislikes[k])) this.dislikes[k] = []; 1501 this.hideDislikes = this._read("magnatune.hideDislikes", true); 1502 this.playlists = this._read("magnatune.playlists", []); 1503 this.recentlyPlayed = this._read("magnatune.recentlyPlayed", []); 1504 if (!Array.isArray(this.recentlyPlayed)) this.recentlyPlayed = []; 1505 this._seq = this._read("magnatune.plseq", 1); 1506 this.member = this._read("magnatune.member", { loggedIn: false, user: "" }); 1507 this.dlAlbum = localStorage.getItem("magnatune.dl.album") || "vbr"; 1508 this.dlSong = localStorage.getItem("magnatune.dl.song") || "mp3"; 1509 this.theme = localStorage.getItem("magnatune.theme") || "system"; 1510 }, 1511 setTheme(v) { this.theme = v; localStorage.setItem("magnatune.theme", v); applyTheme(); try { Sync.markDirty(); } catch (e) {} }, 1512 setDlAlbum(v) { this.dlAlbum = v; localStorage.setItem("magnatune.dl.album", v); try { Sync.markDirty(); } catch (e) {} }, 1513 setDlSong(v) { this.dlSong = v; localStorage.setItem("magnatune.dl.song", v); try { Sync.markDirty(); } catch (e) {} }, 1514 setMember(m) { this.member = m; localStorage.setItem("magnatune.member", JSON.stringify(m)); }, 1515 _save() { 1516 localStorage.setItem("magnatune.favorites", JSON.stringify(this.favs)); 1517 localStorage.setItem("magnatune.dislikes", JSON.stringify(this.dislikes)); 1518 localStorage.setItem("magnatune.playlists", JSON.stringify(this.playlists)); 1519 localStorage.setItem("magnatune.plseq", JSON.stringify(this._seq)); 1520 this._plSet = null; // invalidate the playlist-membership cache 1521 try { Sync.markDirty(); } catch (e) {} // cloud sync (members only) â todo 10355013029 1522 }, 1523 // Set of every song id that appears in any playlist (drives the add-to-playlist icon). 1524 playlistedSongs() { 1525 if (!this._plSet) this._plSet = new Set((this.playlists || []).flatMap((p) => p.songIds)); 1526 return this._plSet; 1527 }, 1528 isFav(kind, id) { return this.favs[kind].includes(id); }, 1529 toggleFav(kind, id) { 1530 const a = this.favs[kind], i = a.indexOf(id); 1531 if (i >= 0) { a.splice(i, 1); } 1532 else { a.unshift(id); const d = this.dislikes[kind], di = d.indexOf(id); if (di >= 0) d.splice(di, 1); } 1533 this._save(); return i < 0; // can't both love and dislike: favoriting clears a dislike 1534 }, 1535 isDisliked(kind, id) { return this.dislikes[kind].includes(id); }, 1536 toggleDislike(kind, id) { 1537 const a = this.dislikes[kind], i = a.indexOf(id); 1538 if (i >= 0) { a.splice(i, 1); } 1539 // Disliking clears any matching favorite â but genres have no favorite bucket, so guard it. 1540 else { a.unshift(id); const f = this.favs[kind]; if (f) { const fi = f.indexOf(id); if (fi >= 0) f.splice(fi, 1); } } 1541 this._save(); return i < 0; 1542 }, 1543 setHideDislikes(v) { this.hideDislikes = !!v; localStorage.setItem("magnatune.hideDislikes", JSON.stringify(this.hideDislikes)); try { Sync.markDirty(); } catch (e) {} },
1544 playlist(id) { return this.playlists.find((p) => p.id === id); }, 1545 createPlaylist(name) { const pl = { id: this._seq++, name, songIds: [] }; this.playlists.push(pl); this._save(); return pl; }, 1546 addToPlaylist(id, songIds) { const p = this.playlist(id); if (!p) return; for (const s of songIds) if (!p.songIds.includes(s)) p.songIds.push(s); this._save(); }, 1547 removeFromPlaylist(id, songId) { const p = this.playlist(id); if (p) { p.songIds = p.songIds.filter((s) => s !== songId); this._save(); } }, 1548 removeSongsFromAllPlaylists(ids) { const set = new Set(ids); for (const p of this.playlists) p.songIds = p.songIds.filter((s) => !set.has(s)); this._save(); }, 1549 renamePlaylist(id, name) { const p = this.playlist(id); if (p) { p.name = name; this._save(); } }, 1550 deletePlaylist(id) { this.playlists = this.playlists.filter((p) => p.id !== id); this._save(); }, 1551 // "Recently Played" â an auto-managed list, most-recent-first, de-duplicated (a replay 1552 // moves the song to the top), capped at RECENT_CAP. Written on every track start; kept in 1553 // its own localStorage key (hot path, independent of the _save() bundle above). 1554 recordPlay(songId) { 1555 if (songId == null) return; 1556 const a = this.recentlyPlayed, i = a.indexOf(songId); 1557 if (i >= 0) a.splice(i, 1); 1558 a.unshift(songId); 1559 if (a.length > RECENT_CAP) a.length = RECENT_CAP; 1560 localStorage.setItem("magnatune.recentlyPlayed", JSON.stringify(a)); 1561 try { Sync.markDirty(); } catch (e) {} 1562 }, 1563 clearRecentlyPlayed() { this.recentlyPlayed = []; localStorage.setItem("magnatune.recentlyPlayed", JSON.stringify([])); try { Sync.markDirty(); } catch (e) {} }, 1564 // Snapshot the synced keys as a plain object (the canonical, client-neutral blob â 1565 // ids are catalog ids shared with the iOS app). schema=1. Cloud sync (todo 10355013029). 1566 localBlob() { 1567 return { 1568 schema: 1, 1569 favorites: this.favs, dislikes: this.dislikes, hideDislikes: this.hideDislikes, 1570 playlists: this.playlists, plseq: this._seq, recentlyPlayed: this.recentlyPlayed, 1571 dlAlbum: this.dlAlbum, dlSong: this.dlSong, theme: this.theme, 1572 }; 1573 }, 1574 // Overwrite local state from a merged blob and persist every key at once (does NOT 1575 // go through the per-field setters, so it won't re-trigger a sync push per field). 1576 adoptBlob(b) { 1577 if (b.favorites && typeof b.favorites === "object") { 1578 this.favs = b.favorites; 1579 for (const k of ["song", "album", "artist"]) if (!Array.isArray(this.favs[k])) this.favs[k] = []; 1580 } 1581 if (b.dislikes && typeof b.dislikes === "object") { 1582 this.dislikes = b.dislikes; 1583 for (const k of ["song", "album", "artist", "genre"]) if (!Array.isArray(this.dislikes[k])) this.dislikes[k] = []; 1584 } 1585 if (typeof b.hideDislikes === "boolean") this.hideDislikes = b.hideDislikes; 1586 if (Array.isArray(b.playlists)) this.playlists = b.playlists; 1587 if (typeof b.plseq === "number") this._seq = b.plseq; 1588 if (Array.isArray(b.recentlyPlayed)) this.recentlyPlayed = b.recentlyPlayed.slice(0, RECENT_CAP); 1589 if (typeof b.dlAlbum === "string") this.dlAlbum = b.dlAlbum; 1590 if (typeof b.dlSong === "string") this.dlSong = b.dlSong; 1591 if (typeof b.theme === "string") this.theme = b.theme; 1592 localStorage.setItem("magnatune.favorites", JSON.stringify(this.favs)); 1593 localStorage.setItem("magnatune.dislikes", JSON.stringify(this.dislikes)); 1594 localStorage.setItem("magnatune.hideDislikes", JSON.stringify(this.hideDislikes)); 1595 localStorage.setItem("magnatune.playlists", JSON.stringify(this.playlists)); 1596 localStorage.setItem("magnatune.plseq", JSON.stringify(this._seq)); 1597 localStorage.setItem("magnatune.recentlyPlayed", JSON.stringify(this.recentlyPlayed)); 1598 localStorage.setItem("magnatune.dl.album", this.dlAlbum); 1599 localStorage.setItem("magnatune.dl.song", this.dlSong); 1600 localStorage.setItem("magnatune.theme", this.theme); 1601 this._plSet = null; 1602 }, 1603}; 1604const orderByIds = (rows, key, ids) => { const m = new Map(rows.map((r) => [r[key], r])); return ids.map((id) => m.get(id)).filter(Boolean); }; 1605 1606/* ===================== cloud sync of member settings (todo 10355013029) ===================== 1607 * Members' favorites/dislikes/playlists/recently-played/prefs live in localStorage, so they 1608 * don't follow the member across devices. This mirrors that blob to the server 1609 * (/membership/settings) â pulled+merged on login, pushed on change (debounced, only-if-changed, 1610 * at most once/60s), with a sendBeacon flush on unload. Members only; a signed-out user is 1611 * unaffected. The server stores one opaque JSON blob per member and is client-agnostic, so the 1612 * iOS app can adopt the same endpoint later (shared catalog ids). */ 1613const SYNC_MIN_INTERVAL = 60000; // never POST more than once a minute 1614const SCALAR_DEFAULT = { hideDislikes: true, dlAlbum: "vbr", dlSong: "mp3", theme: "system" }; 1615const Sync = { 1616 rev: 0, lastHash: "", base: {}, _timer: null, _lastPush: 0, _pulling: false, 1617 init() { 1618 this.rev = UserData._read("magnatune.sync.rev", 0) || 0; 1619 this.lastHash = localStorage.getItem("magnatune.sync.hash") || ""; 1620 this.base = UserData._read("magnatune.sync.base", {}) || {}; 1621 }, 1622 _persistMeta() { 1623 try { 1624 localStorage.setItem("magnatune.sync.rev", JSON.stringify(this.rev)); 1625 localStorage.setItem("magnatune.sync.hash", this.lastHash); 1626 localStorage.setItem("magnatune.sync.base", JSON.stringify(this.base)); 1627 } catch (e) {} 1628 }, 1629 // The blob we upload: last-known remote (preserves keys other clients set, e.g. iOS-only 1630 // prefs) with our own known keys overlaid. 1631 buildBlob() { return Object.assign({}, this.base, UserData.localBlob()); }, 1632 _hash(obj) { const s = JSON.stringify(obj); let h = 0; for (let i = 0; i < s.length;
1632 i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h + ":" + s.length; }, 1633 _uniq(arr) { const seen = new Set(), out = []; for (const x of arr || []) if (!seen.has(x)) { seen.add(x); out.push(x); } return out; }, 1634 _unionKinds(a, b, kinds) { 1635 const out = {}; 1636 for (const k of kinds) out[k] = this._uniq([...(a && a[k] || []), ...(b && b[k] || [])]); 1637 return out; 1638 }, 1639 // Merge remote (another device) into local. Sets union; recently-played recency-merged 1640 // (local first â this device is "now"); scalars keep an explicit local choice but adopt 1641 // remote when local is still the default (a fresh device inherits the member's prefs). 1642 merge(local, remote) { 1643 const m = Object.assign({}, remote, local); // start from union of keys; known keys fixed below 1644 m.schema = 1; 1645 m.favorites = this._unionKinds(local.favorites, remote.favorites, ["song", "album", "artist"]); 1646 m.dislikes = this._unionKinds(local.dislikes, remote.dislikes, ["song", "album", "artist", "genre"]); 1647 m.recentlyPlayed = this._uniq([...(local.recentlyPlayed || []), ...(remote.recentlyPlayed || [])]).slice(0, RECENT_CAP); 1648 // playlists: union by id (merge songIds; keep local name on collision), then remote-only 1649 const byId = new Map(); 1650 for (const p of remote.playlists || []) byId.set(p.id, { id: p.id, name: p.name, songIds: (p.songIds || []).slice() }); 1651 for (const p of local.playlists || []) { 1652 const e = byId.get(p.id); 1653 if (e) { e.name = p.name; e.songIds = this._uniq([...(p.songIds || []), ...e.songIds]); } 1654 else byId.set(p.id, { id: p.id, name: p.name, songIds: (p.songIds || []).slice() }); 1655 } 1656 m.playlists = [...byId.values()]; 1657 m.plseq = Math.max(local.plseq || 1, remote.plseq || 1); 1658 for (const k of ["hideDislikes", "dlAlbum", "dlSong", "theme"]) { 1659 const lv = local[k], rv = remote[k]; 1660 m[k] = (lv !== undefined && lv !== SCALAR_DEFAULT[k]) ? lv : (rv !== undefined ? rv : lv); 1661 } 1662 return m; 1663 }, 1664 applyRemote(remoteObj) { 1665 this.base = Object.assign({}, remoteObj); // retain unknown keys for round-trip 1666 const merged = this.merge(UserData.localBlob(), remoteObj); 1667 UserData.adoptBlob(merged); 1668 applyTheme(); 1669 if (window.Suppress && Suppress.rebuild) Suppress.rebuild(); 1670 }, 1671 async pull() { 1672 if (!isMember() || this._pulling) return; 1673 this._pulling = true; 1674 try { 1675 const r = await fetch("/membership/settings", { credentials: "same-origin" }).then((r) => r.json()); 1676 if (r && r.ok) { 1677 this.rev = r.rev || 0; 1678 if (r.data) this.applyRemote(r.data); else this.base = {}; 1679 this.lastHash = ""; // force a reconcile push if local adds anything 1680 this._persistMeta(); 1681 this.maybePush(true); // push the merged union back up 1682 if (!MAG.loading) route(); // reflect merged data 1683 } 1684 } catch (e) {} finally { this._pulling = false; } 1685 }, 1686 maybePush(immediate) { 1687 if (!isMember()) return; 1688 if (this._hash(this.buildBlob()) === this.lastHash) return; 1689 const wait = Math.max(0, SYNC_MIN_INTERVAL - (Date.now() - this._lastPush)); 1690 clearTimeout(this._timer); 1691 this._timer = setTimeout(() => this._doPush(), (immediate && wait === 0) ? 0 : wait); 1692 }, 1693 markDirty() { this.maybePush(false); }, 1694 async _doPush() { 1695 if (!isMember()) return; 1696 const blob = this.buildBlob(), h = this._hash(blob); 1697 if (h === this.lastHash) return; 1698 this._lastPush = Date.now(); 1699 try { 1700 const body = "rev=" + encodeURIComponent(this.rev) + "&data=" + encodeURIComponent(JSON.stringify(blob)); 1701 const r = await fetch("/membership/settings", { 1702 method: "POST", credentials: "same-origin", 1703 headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, 1704 }).then((r) => r.json()); 1705 if (r.ok) { this.rev = r.rev; this.base = blob; this.lastHash = h; this._persistMeta(); } 1706 else if (r.conflict) { // another device won â merge its blob and retry 1707 this.rev = r.rev || 0; 1708 if (r.data) this.applyRemote(r.data); 1709 this.lastHash = ""; 1710 this._persistMeta(); 1711 this.maybePush(true); 1712 if (!MAG.loading) route(); 1713 } 1714 } catch (e) {} 1715 }, 1716 flushBeacon() { 1717 if (!isMember()) return; 1718 const blob = this.buildBlob(); 1719 if (this._hash(blob) === this.lastHash) return; 1720 try { 1721 const body = "rev=" + encodeURIComponent(this.rev) + "&data=" + encodeURIComponent(JSON.stringify(blob)); 1722 navigator.sendBeacon("/membership/settings", new Blob([body], { type: "application/x-www-form-urlencoded" })); 1723 } catch (e) {} 1724 }, 1725}; 1726 1727/* ---- dislike suppression ---- 1728 * Disliked songs/albums/artists are hidden everywhere while UserData.hideDislikes is on. 1729 * Expanded through the catalog: a disliked artist also suppresses its albums + s
1729ongs; a 1730 * disliked album also suppresses its songs. Rebuilt whenever a dislike changes. */ 1731const Suppress = { 1732 artists: new Set(), albums: new Set(), songs: new Set(), 1733 genres: new Set(), tags: new Set(), playlists: new Set(), 1734 rebuild() { 1735 const A = new Set(UserData.dislikes.artist); 1736 const AL = new Set(UserData.dislikes.album); 1737 const S = new Set(UserData.dislikes.song); 1738 const G = new Set(UserData.dislikes.genre); 1739 for (const aid of UserData.dislikes.artist) 1740 for (const r of q("select album_id from albums where artist_id=?", [aid])) AL.add(r.album_id); 1741 // A disliked genre hides every album in it; remember those albums + their artists. 1742 const genreAlbums = new Set(), candidates = new Set(); 1743 for (const gid of UserData.dislikes.genre) 1744 for (const r of q("select ga.album_id, al.artist_id from genres_albums ga join albums al on al.album_id=ga.album_id where ga.genre_id=?", [gid])) { 1745 AL.add(r.album_id); genreAlbums.add(r.album_id); candidates.add(r.artist_id); 1746 } 1747 // Hide an artist whose every album is in a disliked genre. 1748 for (const aid of candidates) { 1749 if (A.has(aid)) continue; 1750 const albums = q("select album_id from albums where artist_id=?", [aid]); 1751 if (albums.length && albums.every((r) => genreAlbums.has(r.album_id))) A.add(aid); 1752 } 1753 // Every suppressed album drags its songs along. 1754 for (const alid of AL) 1755 for (const r of q("select song_id from songs where album_id=?", [alid])) S.add(r.song_id); 1756 // A tag with no surviving album, or a Featured playlist with no surviving song, is hidden. 1757 const T = new Set(), P = new Set(); 1758 for (const r of q("select collections_id from collections")) { 1759 const al = q("select album_id from collections_albums where collection_id=?", [r.collections_id]); 1760 if (al.every((x) => AL.has(x.album_id))) T.add(r.collections_id); 1761 } 1762 for (const r of q("select playlist_id from playlists where owner_id=0")) { 1763 const ss = q("select song_id from playlist_songs where playlist_id=?", [r.playlist_id]); 1764 if (ss.every((x) => S.has(x.song_id))) P.add(r.playlist_id); 1765 } 1766 this.artists = A; this.albums = AL; this.songs = S; 1767 this.genres = G; this.tags = T; this.playlists = P; 1768 }, 1769 album(id) { return UserData.hideDislikes && this.albums.has(id); }, 1770 artist(id) { return UserData.hideDislikes && this.artists.has(id); }, 1771 song(id) { return UserData.hideDislikes && this.songs.has(id); }, 1772 genre(id) { return UserData.hideDislikes && this.genres.has(id); }, 1773 tag(id) { return UserData.hideDislikes && this.tags.has(id); }, 1774 playlist(id) { return UserData.hideDislikes && this.playlists.has(id); }, 1775}; 1776// Tally per-key counts (albums per genre/tag, songs per featured playlist) that EXCLUDE 1777// anything hidden by "Hide things I dislike", so the numbers match what's actually shown. 1778// `sql` returns (keyCol, <album_id|song_id>) pairs; kind is "album" or "song". 1779function liveCounts(sql, keyCol, kind) { 1780 const idCol = kind === "song" ? "song_id" : "album_id", m = new Map(); 1781 for (const r of q(sql)) { if (Suppress[kind](r[idCol])) continue; m.set(r[keyCol], (m.get(r[keyCol]) || 0) + 1); } 1782 return m; 1783} 1784 1785/* ---- heart + dislike + add-to-playlist controls ---- */ 1786// Broken-heart (Munch's Scream's cousin) icons: Font Awesome heart-crack â light outline 1787// when not disliked, solid when disliked (the solid only shows while "Hide things I dislike" 1788// is off, since disliked items are otherwise suppressed). 1789const HC_LIGHT = `<span class="hc m-hc-o" aria-hidden="true"></span>`; 1790const HC_SOLID = `<span class="hc m-hc" aria-hidden="true"></span>`; 1791 1792// Favorite heart icons (Font Awesome heart â solid when favorited, light outline when not), 1793// cropped to the path bounds so they render the same size as the dislike broken-heart. 1794const HEART_SOLID = `<span class="hc m-heart" aria-hidden="true"></span>`; 1795const HEART_LIGHT = `<span class="hc m-heart-o" aria-hidden="true"></span>`; 1796// Add-to-playlist icons (album-collection duotone): circle-plus = not yet on a playlist, 1797// circle-user = this song/album/artist already has song(s) on a playlist. 1798const PL_ADD = `<span class="ci m-pladd" aria-hidden="true"></span>`;
1799const PL_HAS = `<span class="ci m-plhas" aria-hidden="true"></span>`; 1800function addIcon(on) { return on ? PL_HAS : PL_ADD; } 1801 1802// Download icon (Font Awesome download-light, cropped to bounds). 1803const DL_ICON = `<span class="ci m-dl" aria-hidden="true"></span>`; 1804function isMember() { return !!(UserData.member && UserData.member.loggedIn); } 1805// Download buttons shown only to members. Album â same-origin zip endpoint; song â the 1806// same-origin per-track file (member-gated by m3_music_serve). Both open a new browser 1807// tab to do the actual download â no external he3 / download.magnatune.com dependency. 1808function dlAlbumBtn(sku, big) { 1809 if (!sku) return ""; 1810 // Members download for free. Everyone else gets a buy-download affordance that 1811 // opens the server-rendered explainer/checkout page ($8, 100% to the musician). 1812 if (isMember()) 1813 return `<button class="addbtn${big ? " big" : ""}" title="Download album" onclick="MAG.downloadAlbum(event,'${esc(sku)}')">${DL_ICON}</button>`; 1814 return `<a class="addbtn${big ? " big" : ""}" title="Buy & download this album ($8)" href="/buy/album?sku=${encodeURIComponent(sku)}">${DL_ICON}</a>`; 1815} 1816function dlSongBtn(t) { 1817 if (!isMember()) return ""; 1818 return `<button class="addbtn" title="Download song" onclick="MAG.downloadSong(event,${t.songId})">${DL_ICON}</button>`; 1819} 1820MAG.downloadAlbum = (e, sku) => { 1821 e.stopPropagation(); 1822 window.open(`/membership/download3?sku=${encodeURIComponent(sku)}&format=${UserData.dlAlbum}`, "_blank"); 1823}; 1824MAG.downloadSong = (e, id) => { 1825 e.stopPropagation(); 1826 const r = Q.songsByIds([id])[0]; 1827 if (!r) return; 1828 const stem = (r.mp3 || "").replace(/\.mp3$/i, ""); 1829 window.open(`/music/${encodeURIComponent(r.artist)}/${encodeURIComponent(r.album)}/${encodeURIComponent(stem)}.${UserData.dlSong}`, "_blank"); 1830}; 1831function favBtn(kind, id) { 1832 const on = UserData.isFav(kind, id); 1833 return `<button class="fav${on ? " on" : ""}" title="Favorite" onclick="MAG.toggleFav(event,'${kind}',${id})">${on ? HEART_SOLID : HEART_LIGHT}</button>`; 1834} 1835function dislikeBtn(kind, id) { 1836 const on = UserData.isDisliked(kind, id); 1837 const title = on ? "Disliked â tap to undo" : "I dislike this (hide it)"; 1838 return `<button class="dislike${on ? " on" : ""}" title="${title}" onclick="MAG.toggleDislike(event,'${kind}',${id})">${on ? HC_SOLID : HC_LIGHT}</button>`; 1839} 1840// Keep favorites non-redundant (mirrors the iOS app): a favorited song whose album OR 1841// artist is also favorited is dropped; a favorited album whose artist is favorited is 1842// dropped â the broadest favorite is kept. Returns true if anything was removed. 1843function dedupeFavorites() { 1844 const f = UserData.favs; 1845 if (!f.song.length && !f.album.length) return false; 1846 const favAlbums = new Set(f.album), favArtists = new Set(f.artist); 1847 let changed = false; 1848 if (f.song.length) { 1849 const ph = f.song.map(() => "?").join(","); 1850 const meta = new Map(q(`select s.song_id, s.album_id, al.artist_id from songs s join albums al on al.album_id=s.album_id where s.song_id in (${ph})`, f.song).map((r) => [r.song_id, r])); 1851 const keep = f.song.filter((sid) => { const m = meta.get(sid); return !(m && (favAlbums.has(m.album_id) || favArtists.has(m.artist_id))); }); 1852 if (keep.length !== f.song.length) { f.song = keep; changed = true; } 1853 } 1854 if (f.album.length && f.artist.length) { 1855 const ph = f.album.map(() => "?").join(","); 1856 const artistOf = new Map(q(`select album_id, artist_id from albums where album_id in (${ph})`, f.album).map((r) => [r.album_id, r.artist_id])); 1857 const keep = f.album.filter((aid) => !favArtists.has(artistOf.get(aid))); 1858 if (keep.length !== f.album.length) { f.album = keep; changed = true; } 1859 } 1860 if (changed) UserData._save(); 1861 return changed; 1862} 1863MAG.toggleFav = (e, kind, id) => { 1864 e.stopPropagation(); 1865 const wasDisliked = UserData.isDisliked(kind, id); 1866 const on = UserData.toggleFav(kind, id); 1867 const deduped = on ? dedupeFavorites() : false; 1868 const b = e.currentTarget; b.classList.toggle("on", on); b.innerHTML = on ? HEART_SOLID : HEART_LIGHT; 1869 // Favoriting may clear a dislike or drop a redundant favorite â full re-render then. 1870 if (deduped || wasDisliked || (location.hash || "").split("/")[1] === "favorites") route(); 1871}; 1872MAG.toggleDislike = (e, kind, id) => { 1873 e.stopPropagation(); 1874 e.preventDefault(); // the genre/tag rows are <a> links â don't navigate when toggling 1875 UserData.toggleDislike(kind, id); 1876 Suppress.rebuild(); 1877 route(); // re-render: suppression hides it (hide on) or flips the icon + heart (hide off) 1878};
1879// Now-playing "heart-broken" button: dislike the current song, then skip to the next. 1880// Idempotent on the dislike (never un-dislikes); suppression applies on the next render. 1881MAG.dislikeAndSkip = () => { 1882 const t = Player.current(); if (!t) return; 1883 if (!UserData.isDisliked("song", t.songId)) UserData.toggleDislike("song", t.songId); 1884 Suppress.rebuild(); 1885 toast("Disliked â skipping"); 1886 Player.next(); 1887}; 1888// The playlist button is an add/remove toggle: plus = add (opens the sheet), minus = the 1889// item already has song(s) on a playlist, so clicking removes them from all playlists. 1890function togglePlaylist(songIds) { 1891 if (songIds.some((id) => UserData.playlistedSongs().has(id))) { 1892 UserData.removeSongsFromAllPlaylists(songIds); 1893 toast(songIds.length > 1 ? "Removed from playlists" : "Removed from playlist"); 1894 route(); 1895 } else { 1896 openAddToPlaylist(songIds); 1897 } 1898} 1899MAG.addSong = (e, id) => { e.stopPropagation(); togglePlaylist([id]); }; 1900MAG.addAlbum = (e, id) => { e.stopPropagation(); togglePlaylist(Q.albumSongs(id).map((s) => s.song_id)); }; 1901MAG.addArtist = (e, id) => { e.stopPropagation(); togglePlaylist(Q.artistSongIds(id)); }; 1902MAG.playArtist = (id) => { const ids = Q.artistSongIds(id); const s = orderByIds(Q.songsByIds(ids), "song_id", ids).map((r) => trk(r)); if (s.length) Player.play(s, 0); }; 1903MAG.removeFromPlaylist = (e, plId, songId) => { e.stopPropagation(); UserData.removeFromPlaylist(plId, songId); route(); }; 1904MAG.deletePlaylist = (id) => { if (confirm("Delete this playlist?")) { UserData.deletePlaylist(id); go("playlists"); } }; 1905MAG.renamePlaylist = (id) => { const p = UserData.playlist(id); const n = prompt("Rename playlist", p ? p.name : ""); if (n && n.trim()) { UserData.renamePlaylist(id, n.trim()); route(); } }; 1906MAG.clearRecentlyPlayed = () => { if (confirm("Clear your recently-played history?")) { UserData.clearRecentlyPlayed(); route(); } }; 1907 1908function openAddToPlaylist(songIds) { 1909 if (!songIds || !songIds.length) return; 1910 const pls = UserData.playlists; 1911 const o = document.createElement("div"); 1912 o.className = "sheet-overlay"; 1913 o.innerHTML = 1914 `<div class="sheet"> 1915 <div class="sheet-title">Add ${songIds.length} song${songIds.length > 1 ? "s" : ""} toâ¦</div> 1916 <div class="sheet-new"><input id="pl-new" placeholder="New playlist name" autocomplete="off"><button id="pl-create" class="playall">Create</button></div> 1917 <div class="sheet-list">${pls.length 1918 ? pls.map((p) => `<button class="sheet-pl" data-pl="${p.id}"><span>${esc(p.name)}</span><span class="cnt">${p.songIds.length}</span></button>`).join("") 1919 : `<div class="pad" style="padding:1rem">No playlists yet â create one above.</div>`}</div> 1920 <button class="sheet-close">Cancel</button> 1921 </div>`; 1922 o.addEventListener("click", (ev) => { if (ev.target === o) o.remove(); }); 1923 document.body.appendChild(o); 1924 o.querySelector(".sheet-close").onclick = () => o.remove(); 1925 o.querySelector("#pl-create").onclick = () => { 1926 const n = o.querySelector("#pl-new").value.trim(); if (!n) return; 1927 const pl = UserData.createPlaylist(n); UserData.addToPlaylist(pl.id, songIds); o.remove(); toast(`Added to â${n}â`); route(); 1928 }; 1929 o.querySelectorAll(".sheet-pl").forEach((b) => 1930 b.onclick = () => { UserData.addToPlaylist(+b.dataset.pl, songIds); o.remove(); toast("Added to playlist"); route(); }); 1931 o.querySelector("#pl-new").focus(); 1932} 1933function toast(msg) { 1934 let t = el("toast"); 1935 if (!t) { t = document.createElement("div"); t.id = "toast"; t.className = "toast"; document.body.appendChild(t); } 1936 t.textContent = msg; t.classList.add("show"); 1937 clearTimeout(MAG._toastT); MAG._toastT = setTimeout(() => t.classList.remove("show"), 1600); 1938} 1939 1940/* ---- Favorites + Playlists views ---- */ 1941function renderFavorites(c) { 1942 const f = UserData.favs; 1943 const songs = f.song.length ? orderByIds(Q.songsByIds(f.song), "song_id", f.song).map((s) => trk(s)) : []; 1944 const albums = f.album.length ? orderByIds(Q.albumsByIds(f.album), "album_id", f.album) : []; 1945 const artists = f.artist.length ? orderByIds(Q.artistsByIds(f.artist), "artists_id", f.artist) : []; 1946 if (!songs.length && !albums.length && !artists.length) { 1947 c.innerHTML = `<div class="page-title">Favorites</div><div class="pad">No favorites yet. Tap â¡ on any song, album, or artist.</div>`; 1948 return; 1949 } 1950 let html = `<div class="page-title">Favorites</div>`; 1951 if (songs.length) { const lid = registerList(songs); html += `<div class="row-head">Songs <button class="playall sm" data-list="${lid}" onclick="MAG.playFrom('${lid}',0)">${playLabel(lid)}</button></div>` + songsHTML(lid, true); } 1952 if (albums.length) {
1953 const alid = registerList(albums.flatMap((al) => Q.albumSongs(al.album_id).map((s) => trk(s)))); 1954 html += `<div class="row-head">Albums <button class="playall sm" data-list="${alid}" onclick="MAG.playFrom('${alid}',0)">${playLabel(alid)}</button></div>` + grid(albums.map(albumCell)); 1955 } 1956 if (artists.length) { 1957 const ids = artists.flatMap((ar) => Q.artistSongIds(ar.artists_id)); 1958 const arid = registerList(orderByIds(Q.songsByIds(ids), "song_id", ids).map((s) => trk(s))); 1959 html += `<div class="row-head">Artists <button class="playall sm" data-list="${arid}" onclick="MAG.playFrom('${arid}',0)">${playLabel(arid)}</button></div>` + artistList(artists); 1960 } 1961 c.innerHTML = html; 1962} 1963function renderPlaylists(c) { 1964 const pls = UserData.playlists; 1965 // Auto-managed "Recently Played" is always pinned at the top of the list (not user-created, 1966 // can't be renamed or deleted â it fills itself as you listen). 1967 const recentRow = `<a href="#/recent"><span><i class="fa-solid fa-clock-rotate-left" style="margin-right:.5rem;opacity:.65"></i>Recently Played</span><span class="cnt">${UserData.recentlyPlayed.length}</span></a>`; 1968 // "Recommended" â members only; server-side recs from your Recently Played (todo 10355065694). 1969 const recommendedRow = isMember() 1970 ? `<a href="#/recommended"><span><i class="fa-solid fa-wand-magic-sparkles" style="margin-right:.5rem;opacity:.65"></i>Recommended</span></a>` 1971 : ""; 1972 c.innerHTML = `<div class="page-title">Playlists</div>` + 1973 `<div class="sheet-new" style="max-width:560px"><input id="np-name" placeholder="New playlist name" autocomplete="off"><button class="playall" id="np-create">Create</button></div>` + 1974 `<div class="list" style="margin-top:1rem">` + recentRow + recommendedRow + 1975 pls.map((p) => `<a href="#/playlist/${p.id}"><span>${esc(p.name)}</span><span class="cnt">${p.songIds.length}</span></a>`).join("") + 1976 `</div>`; 1977 el("np-create").onclick = () => { const n = el("np-name").value.trim(); if (n) { UserData.createPlaylist(n); route(); } }; 1978} 1979function renderRecentlyPlayed(c) { 1980 // Ordered by the stored id list (most-recent-first); songs that fell out of the catalog are 1981 // dropped. Same layout as a Featured playlist (artwork rows via songsHTML), plus a Clear. 1982 const ids = UserData.recentlyPlayed; 1983 const songs = orderByIds(Q.songsByIds(ids), "song_id", ids).map((s) => trk(s)); 1984 const lid = registerList(songs); 1985 c.innerHTML = `<div class="page-title">Recently Played</div>` + 1986 (songs.length 1987 ? `<div class="pl-actions">` + 1988 `<button class="playall" data-list="${lid}" onclick="MAG.playFrom('${lid}',0)">${playLabel(lid)}</button>` + 1989 `<button class="playall ghost" onclick="MAG.clearRecentlyPlayed()"><i class="fa-solid fa-trash-can"></i> Clear history</button>` + 1990 `</div>` + 1991 songsHTML(lid, true) 1992 : `<div class="pad">Nothing yet â the songs you play will show up here automatically.</div>`); 1993} 1994/* ===================== "Recommended" playlist (todo 10355065694) ===================== 1995 * Server-side recommendations built from the member's Recently Played, reusing the same 1996 * STREAM-based album-similarity as "You might also like" (server: /membership/recommended). 1997 * Members only, honors dislikes, refreshed at most once every 24h (cached in localStorage). */ 1998const Recs = { 1999 CACHE_MS: 24 * 3600 * 1000, 2000 _uniq(a) { const s = new Set(), o = []; for (const x of a) if (x != null && !s.has(x)) { s.add(x); o.push(x); } return o; }, 2001 // recently-played song ids -> album skus, most-recent-first, de-duped (the seed). 2002 seeds() { 2003 const ids = UserData.recentlyPlayed || []; 2004 if (!ids.length) return []; 2005 const ph = ids.map(() => "?").join(","); 2006 const map = new Map(q(`select s.song_id, al.sku from songs s join albums al on al.album_id=s.album_id where s.song_id in (${ph})`, ids).map((r) => [r.song_id, r.sku])); 2007 return this._uniq(ids.map((id) => map.get(id))); 2008 }, 2009 // exclusion keys the server filters on. xalbums = albums the member has already 2010 // favorited OR disliked (don't recommend what they've already judged); xartists =
2011 // disliked artists (page-slugs); xgenres = disliked genres (names). 2012 dislikeParams() { 2013 const d = UserData.dislikes || { album: [], artist: [], genre: [] }; 2014 const f = UserData.favs || { album: [] }; 2015 const col = (ids, sql, key) => ids.length ? q(sql.replace("?#", ids.map(() => "?").join(",")), ids).map((r) => r[key]) : []; 2016 const exAlbumIds = [...new Set([...(d.album || []), ...(f.album || [])])]; 2017 return { 2018 xalbums: col(exAlbumIds, "select sku from albums where album_id in (?#)", "sku"), 2019 xartists: col(d.artist, "select page from artists where artists_id in (?#)", "page"), 2020 xgenres: col(d.genre, "select name from genres where genre_id in (?#)", "name"), 2021 }; 2022 }, 2023 async fetch() { 2024 const seeds = this.seeds(); 2025 const { xalbums, xartists, xgenres } = this.dislikeParams(); 2026 const body = "seeds=" + encodeURIComponent(seeds.join("|")) + 2027 "&xalbums=" + encodeURIComponent(xalbums.join("|")) + 2028 "&xartists=" + encodeURIComponent(xartists.join("|")) + 2029 "&xgenres=" + encodeURIComponent(xgenres.join("|")); 2030 try { 2031 const r = await fetch("/membership/recommended", { 2032 method: "POST", credentials: "same-origin", 2033 headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, 2034 }).then((r) => r.json()); 2035 if (!r || !r.ok) return null; 2036 const skus = (r.albums || []).map((a) => a.sku); 2037 // cache only a real result â an empty list (no seeds yet) should be retried next visit 2038 if (skus.length) { try { localStorage.setItem("magnatune.recs", JSON.stringify({ at: Date.now(), skus })); } catch (e) {} } 2039 return skus; 2040 } catch (e) { return null; } 2041 }, 2042 async get(force) { 2043 if (!force) { 2044 const c = UserData._read("magnatune.recs", null); 2045 if (c && Array.isArray(c.skus) && (Date.now() - c.at) < this.CACHE_MS) return c.skus; 2046 } 2047 const skus = await this.fetch(); 2048 return skus || []; 2049 }, 2050 // ordered album rows for the returned skus. Filtered at render (not just server-side) 2051 // so a just-favorited/disliked album disappears immediately, even though the recs 2052 // themselves are cached for 24h: drop hidden-by-dislike, plus any album the member 2053 // has favorited or disliked (you've already judged it â don't recommend it back). 2054 albumsForSkus(skus) { 2055 if (!skus.length) return []; 2056 const ph = skus.map(() => "?").join(","); 2057 const rows = q(`select a.album_id,a.name,a.sku,ar.name artist from albums a join artists ar on ar.artists_id=a.artist_id where a.sku in (${ph})`, skus); 2058 const bySku = new Map(rows.map((r) => [r.sku, r])); 2059 const fav = new Set(UserData.favs && UserData.favs.album || []); 2060 const dis = new Set(UserData.dislikes && UserData.dislikes.album || []); 2061 return skus.map((s) => bySku.get(s)).filter(Boolean) 2062 .filter((a) => !Suppress.album(a.album_id) && !fav.has(a.album_id) && !dis.has(a.album_id)); 2063 }, 2064}; 2065function renderRecommended(c) { 2066 if (!isMember()) { 2067 c.innerHTML = `<div class="page-title">Recommended</div><div class="pad">Log in to your Magnatune membership to get personal recommendations â <a href="#/settings">go to Settings</a>.</div>`; 2068 return; 2069 } 2070 c.innerHTML = `<div class="page-title">Recommended</div><div class="pad" id="rec-status">Finding music youâll likeâ¦</div>`; 2071 Recs.get().then((skus) => { 2072 const host = el("content"); 2073 if (!host || currentToken().split("/").filter(Boolean)[0] !== "recommended") return; // user navigated away 2074 const rows = Recs.albumsForSkus(skus); 2075 if (!rows.length) { 2076 host.innerHTML = `<div class="page-title">Recommended</div><div class="pad">Play a few albums first â recommendations are based on your Recently Played.</div>`; 2077 return; 2078 } 2079 const lid = registerList(rows.flatMap((a) => Q.albumSongs(a.album_id).map((s) => trk(s)))); 2080 host.innerHTML = `<div class="page-title">Recommended</div>` + 2081 `<div class="section-sub" style="margin-top:-.8rem;margin-bottom:1rem">Based on your Recently Played · updates daily</div>` + 2082 catPlay(lid) + 2083 `<div class="grid">` + rows.map((a) =>
2083 albumCell(a)).join("") + `</div>`; 2084 LazyImg.scan(host); 2085 highlightPlayingRow(); 2086 }); 2087} 2088function renderUserPlaylist(c, id) { 2089 const p = UserData.playlist(id); 2090 if (!p) { c.innerHTML = `<div class="pad">Playlist not found.</div>`; return; } 2091 const songs = orderByIds(Q.songsByIds(p.songIds), "song_id", p.songIds).map((s) => trk(s)); 2092 const lid = registerList(songs); 2093 let html = `<div class="page-title">${esc(p.name)}</div> 2094 <div class="pl-actions"> 2095 ${songs.length ? `<button class="playall" data-list="${lid}" onclick="MAG.playFrom('${lid}',0)">${playLabel(lid)}</button>` : ""} 2096 <button class="ghostbtn" onclick="MAG.renamePlaylist(${id})">Rename</button> 2097 <button class="ghostbtn danger" onclick="MAG.deletePlaylist(${id})">Delete</button> 2098 </div>`; 2099 if (songs.length) { 2100 html += `<div class="songs">` + songs.map((t, i) => 2101 `<div class="song-row with-sub" data-sid="${t.songId}" onclick="MAG.playFrom('${lid}',${i})"> 2102 <div class="track">${i + 1}</div> 2103 <div class="song-name">${esc(t.title)}</div> 2104 <div class="song-sub">${esc(t.artist)} · ${esc(t.album)}</div> 2105 <div class="dur">${fmtDur(t.duration)}</div> 2106 <div class="song-actions"><button class="addbtn" title="Remove" onclick="MAG.removeFromPlaylist(event,${id},${t.songId})">â</button></div> 2107 </div>`).join("") + `</div>`; 2108 } else html += `<div class="pad">Empty â add songs with the add-to-playlist button on any song, album, or artist.</div>`; 2109 c.innerHTML = html; 2110} 2111 2112/* ---- Previous downloads (member's own album download history) ---- 2113 Reached from Settings -> Download formats -> "Previous downloads". Fetches the 2114 member's download history from /api/mydownloads (server reads MySQL 2115 membership_downloads keyed by the signed member cookie) and links each album to 2116 its page via the in-browser sku->slug map. Album-zip downloads only (per-song 2117 /music/* downloads aren't recorded server-side). */ 2118async function renderDownloads(c) { 2119 const title = `<div class="page-title">Previous downloads</div>`; 2120 if (!isMember()) { 2121 c.innerHTML = title + `<div class="set-section"><div class="set-row"><div class="set-sub">Log in under <b>Settings → Account</b> to see the albums you've downloaded.</div></div></div>`; 2122 return; 2123 } 2124 c.innerHTML = title + `<div class="set-section"><div class="set-row"><div class="set-sub">Loadingâ¦</div></div></div>`; 2125 let items = []; 2126 try { 2127 const r = await fetch("/api/mydownloads").then((r) => r.json()); 2128 if (r && r.ok) items = r.items || []; 2129 } catch (e) { /* fall through to empty */ } 2130 if (!items.length) { 2131 c.innerHTML = title + `<div class="set-section"><div class="set-row"><div class="set-sub">You haven't downloaded any albums yet. Open an album and choose <b>Download</b>.</div></div></div>`; 2132 return; 2133 } 2134 const rows = items.map((it) => { 2135 const label = `${esc(it.album)} <span class="set-sub" style="display:inline">â ${esc(it.artist)}</span>`; 2136 const meta = [it.format ? esc(it.format.toUpperCase()) : "", it.date ? esc(it.date) : ""].filter(Boolean).join(" · "); 2137 const inner = `<span class="toc-label">${label}${meta ? `<div class="set-sub">${meta}</div>` : ""}</span>`; 2138 const tok = P.tokenBySku.get(it.sku); // "album/<aSlug>/<bSlug>" when the album is still in the catalog 2139 return tok 2140 ? `<a class="toc-item" href="${tokenToPath(tok)}" onclick="return MAG.doc(event,'${tok}')">${inner}<i class="fa-solid fa-chevron-right toc-chev"></i></a>` 2141 : `<div class="toc-item">${inner}</div>`; // delisted album: show it, but nowhere to link 2142 }).join(""); 2143 c.innerHTML = title + `<nav class="toc">${rows}</nav>`; 2144} 2145 2146/* ---- Settings (member login, catalog, about) ---- */
2147function renderSettings(c) { 2148 const m = UserData.member; 2149 const account = m.loggedIn 2150 ? `<div class="set-row"><div><div class="set-label">Magnatune membership</div> 2151 <div class="set-sub">Signed in as <b>${esc(m.user)}</b> â tracks stream without the spoken announcement.</div></div> 2152 <button class="ghostbtn" onclick="MAG.logout()">Log out</button></div>` 2153 : `<div class="set-row"><div style="flex:1"> 2154 <div class="set-label">Magnatune membership</div> 2155 <div class="set-sub">Log in to stream member tracks without the end-of-track announcement.</div> 2156 <div class="login-form"> 2157 <input id="mu" placeholder="Username" autocomplete="username"> 2158 <input id="mp" type="password" placeholder="Password" autocomplete="current-password"> 2159 <button class="playall" id="mlogin">Log in</button> 2160 </div> 2161 <div class="set-sub" style="margin-top:.5rem"><a href="/member/signup" target="_blank" rel="noopener">Getting a membership →</a></div> 2162 <div class="set-err" id="login-err"></div></div></div>`; 2163 // Members-only audio-quality picker. 2164 const audio = m.loggedIn 2165 ? `<div class="set-section"><div class="set-head">Audio</div> 2166 <div class="set-row"><div style="flex:1"><div class="set-label">Audio quality</div> 2167 <div class="set-sub">Normal streams 96 kbps Opus, High streams 192 kbps Opus. Playback tries Opus everywhere and falls back automatically â to a lower tier if a track isn't encoded at your choice, or to AAC if your browser can't play Opus.</div></div> 2168 <select class="qsel" onchange="MAG.setQuality(this.value)"> 2169 <option value="normal"${Player.quality === "normal" ? " selected" : ""}>Normal</option> 2170 <option value="lossless"${Player.quality === "lossless" ? " selected" : ""}>High</option> 2171 </select></div></div>` 2172 : ""; 2173 const albumFmts = [["vbr", "MP3 â high quality VBR (~88MB)"], ["mp3", "MP3 â 128k (smaller)"], ["aac", "AAC â for iTunes / iPod"], ["alac", "ALAC â Apple Lossless"], ["flac", "FLAC â lossless, open"], ["ogg", "OGG â high quality, open"], ["wav", "WAV â lossless, exact copy"]]; 2174 const songFmts = [["mp3", "MP3 â high quality"], ["ogg", "OGG"], ["flac", "FLAC â lossless"], ["wav", "WAV â lossless"]]; 2175 const opt = (list, cur) => list.map((f) => `<option value="${f[0]}"${cur === f[0] ? " selected" : ""}>${f[1]}</option>`).join(""); 2176 const downloads = m.loggedIn 2177 ? `<div class="set-section"><div class="set-head">Download formats</div> 2178 <div class="set-row"><div style="flex:1"><div class="set-label">Album</div> 2179 <div class="set-sub">Format used when you download a whole album.</div></div> 2180 <select class="qsel" onchange="UserData.setDlAlbum(this.value)">${opt(albumFmts, UserData.dlAlbum)}</select></div> 2181 <div class="set-row"><div style="flex:1"><div class="set-label">Songs</div> 2182 <div class="set-sub">Format used when you download a single song.</div></div> 2183 <select class="qsel" onchange="UserData.setDlSong(this.value)">${opt(songFmts, UserData.dlSong)}</select></div> 2184 <div class="set-row"><div style="flex:1"><div class="set-label">Previous downloads</div> 2185 <div class="set-sub">The albums you've downloaded, linked to their pages.</div></div> 2186 <a class="ghostbtn" href="${tokenToPath("downloads")}" onclick="return MAG.doc(event,'downloads')">Previous downloads</a></div></div>` 2187 : ""; 2188 // About = a table-of-contents list of real document links (not buttons): each has the 2189 // page's real /info/ URL as its href (openable / copyable / new-tab), and a plain click 2190 // soft-navigates in-app via MAG.doc so playback isn't interrupted by a full reload. 2191 const ABOUT_LINKS = [ 2192 ["Why we are not evil", "info/whynotevil"], ["Founder's rant", "info/why"], 2193 ["FAQ", "info/faq"], ["Music licensing", "info/licensing"], 2194 ["Give away our music", "info/give"], ["Podcast our music", "info/podcast"], 2195 ["Gift cards", "info/giftcards"], ["Terms of use", "info/terms"], 2196 ["APIs, RSS & XML", "info/api"], ["Contact Magnatune", "info/contact"], 2197 ["App help", "help"], 2198 ]; 2199 const about = `<div class="set-section"><div class="set-head">About</div> 2200 <nav class="toc">${ABOUT_LINKS.map(([label, tok]) => 2201 `<a class="toc-item" href="${tokenToPath(tok)}" onclick="return MAG.doc(event,'${tok}')"><span class="toc-label">${esc(label)}</span><i class="fa-solid fa-chevron-right toc-chev"></i></a>`).join("")}</nav></div>`; 2202 const appearance = `<div class="set-section"><div class="set-head">Appearance</div> 2203 <div class="set-row"><div style="flex:1"><div class="set-label">Theme</div> 2204 <div class="set-sub">Dark mode for night listening. âSystemâ follows your device's light/dark setting.</div></div> 2205 <select class="qsel" onchange="MAG.setTheme(this.value)"> 2206 <option value="system"${UserData.theme === "system" ? " selected" : ""}>System</option> 2207 <option value="light"${UserData.theme === "light" ? " selected" : ""}>Light</option> 2208 <option value="dark"${UserData.theme === "dark" ? " selected" : ""}>Dark</option> 2209 </select></div></div>`; 2210 c.innerHTML = 2211 `<div class="page-title">Settings</div> 2212 <div class="set-section"><div class="set-head">Account</div>${account}</div> 2213 ${audio} 2214 ${downloads} 2215 ${appearance} 2216 <div class="set-section"><div class="set-head">
2216Catalog</div> 2217 <div class="set-row"><div><div class="set-label">Music catalog</div><div class="set-sub" id="cat-sub">â¦</div></div></div></div> 2218 <div class="set-section"><div class="set-head">Library</div> 2219 <div class="set-row"><div><div class="set-label">Saved in this browser</div> 2220 <div class="set-sub">${UserData.favs.song.length + UserData.favs.album.length + UserData.favs.artist.length} favorites · ${UserData.playlists.length} playlists · ${UserData.dislikes.song.length + UserData.dislikes.album.length + UserData.dislikes.artist.length} disliked</div></div></div></div> 2221 <div class="set-section"><div class="set-head">Dislikes</div> 2222 <div class="set-row"><div style="flex:1"><div class="set-label">Hide things I dislike</div> 2223 <div class="set-sub">Tap the broken-heart icon next to the heart on any song, album, or artist to dislike it. While this is on, disliked things are hidden everywhere. Turn it off to see them again and tap the broken heart to un-dislike.</div></div> 2224 <label class="switch"><input type="checkbox" id="hide-dislikes" ${UserData.hideDislikes ? "checked" : ""} onchange="MAG.setHideDislikes(this.checked)"><span class="track"></span></label></div></div> 2225 <div class="set-section"><div class="set-head">Apps</div> 2226 <div class="set-row"><div style="flex:1"><div class="set-label">Magnatune for Android</div> 2227 <div class="set-sub">Native player. Download the latest APK from GitHub releases and open it to install (you may need to allow installing from your browser / unknown sources).</div></div> 2228 <a class="ghostbtn" href="https://github.com/johnbuckman/magnatune-android/releases" onclick="return MAG.dlLatest(event,'johnbuckman/magnatune-android','apk')" target="_blank" rel="noopener">Download APK</a></div> 2229 <div class="set-row"><div style="flex:1"><div class="set-label">Magnatune for iOS (iPhone/iPad)</div> 2230 <div class="set-sub">Native player for iPhone & iPad. There's no App Store version â install it for free with the Mac app <b>SideStep</b> (takes a couple of minutes). The app (.ipa) is published on <a href="https://github.com/johnbuckman/magnatune-app/releases" target="_blank" rel="noopener">GitHub releases</a>.</div></div> 2231 <a class="ghostbtn" href="${tokenToPath("ios")}" onclick="return MAG.doc(event,'ios')">How to install</a></div> 2232 <div class="set-row"><div style="flex:1"><div class="set-label">Magnatune for OSX</div> 2233 <div class="set-sub">Native player. Download the latest DMG from GitHub releases, open it, and drag Magnatune to Applications. Requires macOS 14 Sonoma or later. Signed & notarized by Apple.</div></div> 2234 <a class="ghostbtn" href="https://github.com/johnbuckman/magnatune-app/releases" onclick="return MAG.dlLatest(event,'johnbuckman/magnatune-app','dmg')" target="_blank" rel="noopener">Download DMG</a></div></div> 2235 ${about}`; 2236 const na = q("select count(*) c from albums")[0].c, ns = q("select count(*) c from songs")[0].c; 2237 el("cat-sub").textContent = `${na.toLocaleString()} albums · ${ns.toLocaleString()} songs â kept up to date automatically`; 2238 if (!m.loggedIn) { 2239 el("mlogin").onclick = () => MAG.login(); 2240 el("mp").addEventListener("keydown", (e) => { if (e.key === "Enter") MAG.login(); }); 2241 } 2242} 2243MAG.setHideDislikes = (on) => { UserData.setHideDislikes(on); route(); }; 2244MAG.setQuality = (q) => Player.setQuality(q); 2245MAG.login = async () => { 2246 const user = el("mu").value.trim(), pw = el("mp").value, err = el("login-err"); 2247 if (!user || !pw) { err.textContent = "Enter username and password."; return; } 2248 err.textContent = "Checkingâ¦"; 2249 try { 2250 const r = await fetch(LOGIN_URL, { 2251 method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, 2252 body: `user=${encodeURIComponent(user)}&pw=${encodeURIComponent(pw)}`, 2253 }).then((r) => r.json()); 2254 if (r.ok) { UserData.setMember({ loggedIn: true, user: r.user }); toast("Logged in"); Sync.pull(); route(); } 2255 else err.textContent = r.error || "Login failed."; 2256 } catch (e) { err.textContent = "Network error."; } 2257}; 2258MAG.logout = () => { fetch("/membership/logout", { method: "POST" }).catch(() => {}); UserData.setMember({ loggedIn: false, user: "" }); toast("Logged out"); route(); }; 2259MAG.info = (page) => go("info/" + page); // in-app About page (was external window.open) 2260// TOC doc link: a plain click soft-navigates in-app (keeps playback); a modified / 2261// middle click falls through to the browser so the real href opens in a new tab. 2262MAG.doc = (e, tok) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button) return true; e.preventDefault(); go(tok); return false; }; 2263MAG.setTheme = (v) => UserData.setTheme(v); 2264// Download the newest GitHub release asset matching `ext` (e.g. "apk"/"dmg") for `repo`. 2265// Resolves the latest release that actually HAS such an asset (the newest release may be
2266// platform-specific, e.g. an iOS-only build with no .dmg), then navigates straight to the 2267// asset â release assets are served as attachments, so the browser downloads it. A modified 2268// or middle click, or any API failure/rate-limit, falls through to the releases page (the 2269// link's href). Kept dependency-free: api.github.com sends permissive CORS, so this runs 2270// straight from the static player. 2271MAG.dlLatest = (e, repo, ext) => { 2272 if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button) return true; // let it open the releases page 2273 e.preventDefault(); 2274 const fallback = `https://github.com/${repo}/releases`; 2275 const toPage = () => window.open(fallback, "_blank", "noopener"); 2276 fetch(`https://api.github.com/repos/${repo}/releases`, { headers: { Accept: "application/vnd.github+json" } }) 2277 .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) 2278 .then((rels) => { 2279 for (const rel of rels) { 2280 const a = (rel.assets || []).find((x) => x.name.toLowerCase().endsWith("." + ext)); 2281 if (a) { window.location.href = a.browser_download_url; return; } 2282 } 2283 toPage(); // no matching asset anywhere â let the user pick from the releases page 2284 }) 2285 .catch(toPage); 2286 return false; 2287}; 2288 2289/* ===================== help page ===================== */ 2290// Shown in the content area like any other section. Opened by clicking the mascot 2291// in the sidebar or Settings -> About -> "App help". 2292const HELP_HTML = `<div class="help"> 2293<h2>Welcome to Magnatune</h2> 2294<p>Magnatune is an independent record label founded in 2003 on a simple idea: be fair to musicians and fair to listeners. Our motto is “we are not evil.”</p> 2295<p>Every album is hand-picked — we accept about 3% of submissions — and you can listen to everything in full before you decide to buy. When you do buy, half the money goes straight to the artist. All the music is Creative Commons licensed, with no copy protection (DRM).</p> 2296<p>This player streams Magnatune’s entire catalog: hundreds of artists across classical, jazz, electronica, rock, ambient, world and more.</p> 2297 2298<h2>Finding your way around</h2> 2299<p>On a wide screen the sections live in the sidebar on the left. On a narrow screen or phone they’re split between a bar at the top (for browsing) and a bar at the bottom.</p> 2300<ul> 2301<li><b>Popular</b> — the most-loved albums, grouped by genre.</li> 2302<li><b>Artists</b>, <b>Albums</b>, <b>Songs</b> — browse the whole catalog.</li> 2303<li><b>Genres</b> — pick a style, then drill into its artists and albums.</li> 2304<li><b>Tags</b> — albums grouped by mood and theme (“Tagged as…”).</li> 2305<li><b>Featured</b> — playlists hand-curated by Magnatune.</li> 2306<li><b>Search</b> — find any artist, album or song by name.</li> 2307<li><b>Favorites</b>, <b>Playlists</b>, <b>Settings</b> — your own music and options.</li> 2308</ul> 2309 2310<h2>Browsing music</h2> 2311<p>Click any artist, album or genre to open it. Album pages list every track; artist pages list their albums.</p> 2312<ul> 2313<li>Click album art or an artist photo to see it full-screen.</li> 2314<li>At the bottom of an album or artist page you’ll find <b>“You might also like”</b> — related music chosen for you.</li> 2315<li>Genre and tag chips on a detail page are clickable, taking you to everything in that genre or tag.</li> 2316</ul> 2317 2318<h2>Playing music</h2> 2319<p>Click a song to play it. The <b>Play</b> button on an album or artist plays the whole thing; turn on <b>Shuffle</b> to mix up the order.</p> 2320<p>The mini-player sits at the bottom. Click it to open the full <b>Now Playing</b> view — there you can click the art, song or album to jump to that album, or the artist name to open the artist.</p> 2321<ul> 2322<li><b>Play / Pause</b>, <b>Previous</b>, <b>Next</b> — the usual transport controls.</li> 2323<li><b>Shuffle</b> plays the queue in random order. <b>Repeat</b> loops the queue forever once it reaches the end — and looped tracks play from memory, with no extra downloading.</li> 2324<li>Drag the progress bar to skip to any point in a track.</li> 2325<li><b>Volume</b> — the slider in the mini-player.</li> 2326<li><b>AirPlay</b> — in Safari, use the AirPlay button to send sound to AirPlay speakers, a HomePod or an Apple TV. (Other browsers don’t offer AirPlay.)</li> 2327<li>Playback appears in your system media controls, so you can play, pause and skip from your keyboard or lock screen.</li> 2328</ul> 2329 2330<h2>Favorites & dislikes</h2> 2331<p>Use the heart and broken-heart icons that appear next to every song, album and artist.</p> 2332<ul> 2333<li><b>Heart</b> — add to your Favorites. Everything you favorite lives under <b>Favorites</b>.</li> 2334<li><b>Broken heart</b> — mark something you’d rather not see. With <b>Hide things I dislike</b> on (<b>Settings</b>), disliked music disappears everywhere; turn it off to bring it back.</li> 2335</ul> 2336 2337<h2>Your playlists</h2> 2338<p>Build your own playlists under <b>Playlists</b>.</p> 2339<ul> 2340<li>Click the <b>add-to-playlist</b> button next to any heart to add a song, a whole album, or an artist’s entire catalog — or to create a new playlist on the spot.</li> 2341<li>Inside a playlist you can remove tracks or delete the playlist.</li> 2342</ul> 2343 2344<h2>Membership & sound quality</h2> 2345<p>You can stream everything for free. Free streams include a short spoken announcement at the end of each track.</p> 2346<p>A Magnatune membership removes the announcement, unlocks higher-quality audio, and lets you download music. Log in under <b>Settings</b> → Account.</p> 2347<ul> 2348<li>Members can choose <b>Normal</b> or <b>Lossless</b> audio quality in <b>Settings</b>.</li> 2349</ul> 2350 2351<h2>Downloading music</h2> 2352<p>When you’re a logged-in member, a download button appears on albums and on individual songs. It downloads the music through your browser, in the format you choose under <b>Settings</b> → Download formats.</p> 2353 2354<h2>Your data</h2> 2355<p>Your favorites, playlists and settings are saved in this browser. They stay on this device and aren’t synced to your other devices or browsers, and clearing your browser data will remove them.</p> 2356 2357<h2>Settings</h2> 2358<p>Everything above is configurable under <b>Settings</b>: membership, audio quality, downloads, dislikes and your library. The catalog updates itself automatically in the background.</p> 2359<p>Tap <b>Why we are not evil</b> or <b>Founder’s rant</b> in <b>Settings</b> to learn more about what Magnatune stands for.</p> 2360<p>Enjoy the music — and thanks for supporting independent artists.</p> 2361</div>`; 2362// Bolded words that name a navigable section -> its hash route. Turns <b>Section</b> 2363// into a link; other <b>bold</b> terms (Play, Shuffle, AirPlay, â¦) stay plain bold. 2364const HELP_LINKS = { 2365 Popular: "popular", Artists: "artist", Albums: "album", Songs: "songs", Genres: "genre", Tags: "tag", 2366 Featured: "featured", Search: "search", Favorites: "favorites", Playlists: "playlists", Settings: "settings", 2367}; 2368function linkifyHelp(html) { 2369 return html.replace(/<b>([^<]+)<\/b>/g, (m, t) => 2370 HELP_LINKS[t] ? `<a class="help-link" href="#/${HELP_LINKS[t]}">${t}</a>` : m); 2371} 2372/* ===================== About / info pages (scraped from magnatune.com /info/) ===================== */ 2373// AUTO-GENERATED from magnatune.com /info/ pages â navim4 in-app About pages. 2374const INFO_PAGES = { 2375 whynotevil: { title: "Why we are not evil", html: `<b>Why Magnatune is not evil</b><p> 2376<li> <b>Fantastic music</b>: we work with artists directly, not with record labels, and all our music is hand-picked. On average, we accept 3% of submissions 2377<p> 2378<li> <b>Perfect audio quality</b>: our music is available in both lossless and smaller-size "lossy" formats 2379<p> 2380<li> <b>Many audio formats</b>: our albums can be download as WAV files, as well as super-high quality VBR MP3s, AAC, Apple Lossless (ALAC), and open source friendly FLAC, OGG and OPUS formats 2381<p> 2382<li> <b>
2382Creative Commons</b>: members get access to all our music under a friendly license that enables sharing and reuse [<a href="http://magnatune.com/info/cc_licensed" target="_blank" rel="noopener">more</a>] 2383<p> 2384<li> <b>Listen to everything</b>: all our albums can be listened to in their entirety before you buy. 2385<p> 2386<li> <b>Download everything</b>: once you've paid, you can download anything from our entire catalog--no limits. 2387<p> 2388<li> <b>Musicians get paid</b>: 50% of your purchase price goes directly to the musician, not to labels and their lawyers 2389<p> 2390<li> <b>Album art</b>: every album includes high quality album art (in both Adobe Acrobat and 300DPI JPG formats) 2391<p> 2392<li> <b>Give to your friends</b>: We encourage you to <a href="http://magnatune.com/give" target="_blank" rel="noopener">give 3 copies</a> of any music from your membership to your friends 2393<p> 2394 2395<li> <b>Artists direct</b>: we sign contracts directly with musicians, so you can rest assured that we can legally license music to you, and no middlemen get in the way of the artist's royalties 2396<p> 2397<li> <b>Podcast-legal</b>: non-commercial <a href="http://magnatune.com/podcast" target="_blank" rel="noopener">podcasters</a> can use our music for free 2398<p> 2399<li> <b>No major labels</b>: we have absolutely nothing to do with major labels, their lawyers or the RIAA 2400<p> 2401<li> <b>Financially support Open Source</b>: we financially support several open source projects such as <a href="http://blogs.magnatune.com/buckman/2006/11/amarok_adds_mag.html" target="_blank" rel="noopener">Amarok</a> and <a href="http://blogs.magnatune.com/buckman/2006/12/rhythmbox_adds_.html" target="_blank" rel="noopener">Rhythmbox</a> 2402<p> 2403<li> <b>No DRM</b>: No copy protection (DRM), so you can do what you like with your music` }, 2404 faq: { title: "Frequently Asked Questions", html: `<b>Frequently Asked Questions</b> 2405 <p> 2406 2407 What does the a purchase of Magnatune's music get you?<br> 2408 How much does it cost? <br> 2409 Do I also get streaming?<br> 2410 What download formats do I get?<br> 2411 Are there any download limits?<br> 2412 Can I really download everything?<br> 2413 Are there members-only features?<br> 2414 How do I download music?<br> 2415 Can I burn CDs?<br> 2416 Do you use copy protection? DRM?<br> 2417 What rights do I have to the music?<br> 2418 Does it work on Linux? Mac? Windows?<br> 2419 Does it work with Firefox? IE? Others?<br> 2420 Can I use my own music player? (winamp? iTunes?)<br> 2421 How are your musicians paid?<br> 2422 Can I share the music I download?<br> 2423 Are there any desktop programs for using Magnatune download membership?<br> 2424 Can I share my login with others?<br> 2425 Do you donate money to open source programs?<br> 2426 Whatever happened to XXX artist? I know I heard them on your site before.<br> 2427 How do I get the music I downloaded into my iTunes library?<br> 2428 How do I add the album cover art to my iTunes library? When I imported the music, there was no artwork.<br> 2429 Can I pay extra to receive a physical copy of the CD?<br> 2430 What about my existing membership subscription?<br><p> 2431Answers: 2432<p><br> 2433 2434<br> 2435<b>What does the a purchase of Magnatune's music get you?</b> 2436<p> 2437 2438You get to listen to everything on the Magnatune web site, without any commercials, and anything you want to download. 2439<p> 2440 2441We offer MP3 and WAV files, and a variety of other formats as well (see below for a list of formats). All without copy protection (DRM). 2442<p> 2443 2444You can also download a PDF of the album artwork. 2445<p> 2446<br> 2447 2448<b>How much does it cost? </b> 2449<p> 2450Our library costs $299 as a one-time payment. <p> 2451Because we aren't evil, fully 50% of your payment goes straight to the musicians (not to lawyers or labels) that you download from. You're doing a good deed by paying for our music! 2452<p> 2453<br> 2454 2455<b>Do I also get streaming?</b> 2456<p> 2457 2458Yes. You can listen to everything on the web site without needing to download it. Plus, the annoying commercials at the end of songs don't appear after you've paid. 2459<p> 2460<br> 2461 2462<b>What download formats do I get?</b> 2463<p> 2464 2465MP3 - high quality VBR as well as 128k MP3 (lower quality)<br> 2466WAV files - perfect quality<br> 2467ALAC - Apple lossless (without DRM)<br> 2468OPUS - high quality open source format.<br> 2469FLAC format - perfect quality open source format<br> 2470AAC files - high quality Apple iTunes only (without DRM)<br> 2471OGG Vorbis - high quality, open source format<br> 2472<p> 2473 2474<br> 2475 2476<b>Are there any download limits?</b> 2477<p> 2478 2479There are no download limits, you are free to download whatever albums you like at any time. 2480<p> 2481<br> 2482 2483<b>Can I really download everything?</b> 2484<p> 2485 2486Yes!<p> 2487<br> 2488 2489<b>Are there members-only features?</b> 2490<p> 2491 2492Yes, paying enables the "favorites" feature.<p> When you are listening to an album using the Magnatune web site, you 2493can mark an album as a favorite. You can then do a number of things with your favorite albums: 2494<p> 24951) one-click add albums to your favorites as you listen to them. Download them later, when it's convenient. 2496<p> 24972) listen to all your favorite albums (sequentially or as a shuffle mi
2497x) on the web with our flash player, or inside your own media player via the m3u/ogg/xspf links. 2498<p> 24993) personal podcast: if you subscribe to your favorites using podcast-download software (such as iTunes), then the albums you mark as favorites are automatically downloaded in the background, and added to your iPod. Favorites are automatically downloaded and added to your Windows Media Player Library, when you use the free "Juice" podcast download program. 2500<p> 25014) iTunes sync: instantly import their favorite Magnatune albums (as streams) into iTunes. 2502<p> 2503 More information about these features is available on these blog pages: 2504<a href="http://blogs.magnatune.com/buckman/2008/06/new-feature-fav.html" target="_blank" rel="noopener">favorites</a> and 2505<a href="http://blogs.magnatune.com/buckman/2008/06/new-feature-exp.html" target="_blank" rel="noopener">instant iTunes import</a>. 2506 2507<p> 2508<br> 2509 2510<b>How do I download music?</b> 2511<p> 2512 2513When you are on an album page on Magnatune, simply click the "download" button. The next page will give you zip files of all the various download formats for that album, as well as a PDF of the artwork. 2514<p> 2515<br> 2516 2517<b>Can I burn CDs?</b> 2518<p> 2519 2520Yes, you can burn CDs from the downloaded files 2521<p> 2522 2523If you are doing that, we recommend you download the WAV format of the album, so that your CD is an exact duplicate of the master CD, and sounds perfect. 2524<p> 2525<br> 2526 2527<b>Do you use copy protection? DRM?</b> 2528<p> 2529 2530We never, ever use copy protection: that would be evil. 2531<p> 2532<br> 2533 2534<b>What rights do I have to the music?</b> 2535<p> 2536 2537You can play the music just as if you had purchased it individually from a store. 2538<p> 2539 2540Put it on your computer, transfer it to your iPod, copy it to a USB key and take it to work, whatever you like. 2541<p> 2542 2543We expect that you will want to share some of the great music you find with your friends, and that's great! 2544<p> 2545 2546However, please don't give a friend more than one album from your download membership per month, as we would like to strike a balance between helping you tell your friends about Magnatune, and having your friends sign up with their own membership. Use your own judgement. 2547<p> 2548 2549<br> 2550 2551<b>Does it work on Linux? Mac? Windows?</b> 2552<p> 2553 2554Yes, because we are simply a web site, and do not use any copy protection, everything works on all operating systems. 2555<p> 2556<br> 2557 2558<b>Does your web site work with Firefox? IE? Opera? Chrome? Others?</b> 2559<p> 2560 2561Yes, all modern web browsers work, and our web site no longer requires Flash. 2562 2563<p> 2564<br> 2565 2566<b>Can I use my own music player? (winamp? iTunes?)</b> 2567<p> 2568 2569The downloads from our web site are plain MP3s and should work in every music player. 2570 2571To stream music with a different (non web browser) player, click the ".m3u" link to play the music in your own music player.<p> 2572<br> 2573 2574<b>How are your musicians paid for my purchase?</b> 2575<p> 2576 257750% of your purchase is allocated to the musicians whose albums you download each month (divided equally based on which albums you downloaded). 2578 2579<p> 2580<br> 2581 2582<b>Can I share the music I download?</b> 2583<p> 2584 2585We expect that you will want to share some of the great music you find with your friends, and that's great! 2586<p> 2587 2588However, please don't give a friend more than one album per month, as we would like to strike a balance between helping you tell your friends about Magnatune, and having your friends sign up on their own. Use your own judgement. 2589<p> 2590<br> 2591 2592<b>Are there any desktop programs for using Magnatune?</b> 2593<p> 2594 2595There are excellent iPhone and Android Phone applications that stream music over the mobile phone network, and both support membership for commercial-free listening. 2596<p> 2597The Linux programs Rhythmbox and Amarok support Magnatune very well and both support Magnatune memberships. 2598 2599<p> 2600<br> 2601 2602<b>Can I share my membership with others?</b> 2603<p> 2604 2605Sorry, but only one person may use your download membership. We may cancel your membership if we find many people using it at the same time. 2606<p> 2607<br> 2608 2609<b>Do you donate money to open source programs?</b><p> 2610Yes, if you pay via either <a href="http://www.gnome.org/projects/rhythmbox/" target="_blank" rel="noopener">Rhythmbox</a> or <a href="http://amarok.kde.org/" target="_blank" rel="noopener">Amarok</a>, 10% of your payment is donated to that project. 2611<p> 2612<br> 2613 2614<b>Whatever happened to XXX artist? I know I heard them on your site before.</b><p> 2615We try to be as fair as possible to our artists: our motto of 'We are 2616Not Evil' is as much for them as it is for the consumer. In some 2617cases, this has meant that artists decided to leave Magnatune to pursue 2618agreements with other record labels that don't allow the non-exclusive relationship we have. 2619We don't always agree with their decision, but we of course respect and abide by it. In those cases, we remove the artist's music from our web site. 2620<p> 2621<br> 2622 2623<b>How do I get the music I downloaded into my iTunes library?</b><p> 2624With Windows, right-click on the downloaded file and select 'Expand All' from the menu that appears. 2625<p> 2626 2627For Macs, just double clicking the download file will do it. Make sure to take note where you put the song files. 2628<p> 2629 2630*Launch iTunes, and select the 'File' option from the menu bar. 2631<p> 2632 2633*Select 'Add to Library'. A new window will come up, and you will need to find the album you downloaded. 2634<p> 2635 2636*Hit 'Edit' and then 'Select All' to highlight all of the songs in the album. 2637<p> 2638 2639*Press the the 'Choose' button. This will add all of the tracks to your iTunes library, and you can then move them around as you would any other song from playlist to iPod etc. 2640<p> 2641<br> 2642 2643<b>How do I add the album cover art to my iTunes library? When I imported the music, there was no artwork.</b><p> 2644In iTunes, not every album from Magnatune will automatically display artwork. This is because iTunes gets its artwork from a 3rd party service which doesn't always have the latest Magnatune releases. You can certainly add artwork into iTunes, however, fairly easily. 2645 2646<p> 2647You can use JPEG, GIF, PNG or Photoshop images to add artwork to any Magnatune albums that may be missing it. A high quality JPEG of the album cover is available on the album download page. 2648<p> 2649 26501. In Browse view, select the album in your iTunes library. 2651<p> 2652Click the Browse button to switch to Browse view so that an Album column appears on the right side of the iTunes window. Click an album to select it. 2653<p> 2654 26552. Choose the File option from the top menu bar, Get Info or press Command-I (Mac) or Ctrl-I (PC). 2656<p> 2657 26583. Click 'Yes' to edit information for multiple items, in this case an entire album. 2659<p> 2660 2661The Multiple Song Information dialog appears. 2662<p> 2663 26644. Enable the Artwork field by selecting its check box. 2665<p> 2666 2667When you add a check mark to a field, iTunes assumes you want that field changed in all the selected songs. Make sure that no other check box is selected except Artwork. 2668<p> 2669 26705. Drag the graphics file to the Artwork panel. 2671<p> 2672 2673Drag the graphics file directly over the blank Artwork field in the Multiple Song Information dialog. 2674<p> 2675 26766. Click OK to make the change. 2677<p> 2678 26797. Click Yes to change the artwork when the warning message appears about changing multiple files. 2680<p> 2681<br> 2682 2683 <b>Can I pay extra to receive a physical copy of the CD?</b><p> 2684We are no longer offering physical CD's. CD burning technology has come 2685to the point where it is easy to create your own copies of any albums 2686you download from Magnatune, and we offer artwork that you can print 2687at home to give your discs a more polished look, if you would like. 2688<p> 2689 2690<b>What about my existing membership subscription?</b> 2691<p> 2692 2693We will continue to honor existing membership subscriptions, but are not offering any new ones.` }, 2694 licensing: { title: "Music Licensing", html: `<b>Music Licensing at Magnatune</b> 2695 2696<p>Magnatune provides you with instant music licensing from our catalog of 20,000 songs, covering a wide variety of genres. With a few mouse clicks, you get CD quality audio files and the right to use the music in your project. 2697No waiting, no haggling, no lawyers. <p> 2698We sell more music licenses online than anyone else: over 7000 licenses in our 12 years of business. 4000 films have used our music. And there have never been any legal problems with any of them. 2699 2700<b>How Does It Work?</b><p> 2701Our music is licensed through two dedicated web sites that we also own and run.<ul> 2702 2703<li><b>
2703<a href="http://ilicensemusic.com" target="_blank" rel="noopener">iLicenseMusic</a></b> is our subscription music licensing service. For a simple monthly fee, you can use any of our music in any way you like. 2704If you're making a film, video, web site, presentation, or any other project which uses music, this is the web site for you. 2705<p> 2706<li><b><a href="http://moodmixes.com" target="_blank" rel="noopener">MoodMixes</a></b> is our background music for business service. We provide your restaurant, store, office or other public 2707space with complete shows, albums, downloads -- whatever you like. 2708 2709</ul> 2710 2711<b>What does this cost?</b><ul> 2712<li><a href="http://ilicensemusic.com" target="_blank" rel="noopener">iLicenseMusic</a> charges a simple $89/month for any use you can imagine. You can choose from any of the songs in our extensive catalog. No lawyers, no salespeople, no haggling. 2713<li><a href="http://moodmixes.com" target="_blank" rel="noopener">MoodMixes</a> starts at $15/month, depending on the size of the space where the music is played. 2714</ul> 2715 2716<b>Can I cancel at any time?</b><ul> 2717<li><a href="http://ilicensemusic.com" target="_blank" rel="noopener">iLicenseMusic</a>: yes, you can cancel at any time, but you will no longer have the right to use our music in new projects or new products. 2718<li><a href="http://moodmixes.com" target="_blank" rel="noopener">MoodMixes</a>: yes, you can cancel at any time, but you will need to stop playing our music at your place of business. 2719</ul> 2720 2721<b>How Can I Use The Music?</b><p> 2722Every day, Magnatune licenses music for use in films, television, web pages, CDs and DVDs, and much more. Other uses include telephone hold music, public space music for retail stores and galleries, music for runway fashion shows, and even themed CD compilations that act as extra revenue streams for shops, hotels, and spas. <a href="http://magnatune.com/info/licensing_examples" target="_blank" rel="noopener">See who has licensed our music</a>. 2723 2724<b>Can I Trust My License From You?</b><p> 2725Yes. Magnatune is a real company, incorporated since 1999 and we have <a href="http://magnatune.com/info/licensing_examples" target="_blank" rel="noopener">hundreds of music licensing clients</a>, including prominent filmmakers, large advertising firms, and video game producers. Our innovative online licensing tool was crafted in close collaboration with top flight entertainment lawyers, and are valid legal documents that have held up under scrutiny. 2726 2727<p><b>What does "Royalty Free Music" Mean?</b><p> 2728Magnatune's music is "royalty free" in the sense that we don't build points into our agreements. In other words, if your project is wildly successful, you don't have to give us any of the profits. <i>However</i>, our music is not "production" or "stock" music. If you're using it for anything other than private enjoyment, <u>you must purchase a license</u> along with the music itself. Fortunately we've made doing this incredibly easy and affordable! 2729 2730<b>What Are the Terms of the Contract?</b><p> 2731Magnatune will not ask to see your work to approve the use. Our process and pricing structure is the same for all buyers, which removes legal fees as a built-in cost. The actual 2732legal license agreements for MoodMixes and iLicenseMusic are on those web sites. 2733 2734<b>Do You Offer "Buy Outs?"</b><p> 2735Absolutely. If you don't want to license our music by subscription, we are happy to negotiate a single "buy out" rate for the rights you need, so that you won't ever have to pay us again. 2736<a href="http://magnatune.com/info/email_us?to=John" target="_blank" rel="noopener">Email Us</a>. 2737 2738<b>Do You Offer Custom Licenses?</b><p> 2739Absolutely. If our standard approach doesn't meet your needs, we are happy to build a custom agreement for you. <a href="http://magnatune.com/info/email_us?to=John" target="_blank" rel="noopener">Email John</a>. 2740 2741<b>I don't want to subscribe!</b><p> 2742Our music licenses are available via subscription because we've found this to be a much better way to operate than having to negotiate lots of individual rights. 2743For example, most people making a film need to distribute DVDs, have their film in festivals, have advertisements, distribute through Netflix and show parts of their 2744film on Youtube. Our simple $89/month fee via <a href="http://ilice
2744nsemusic.com" target="_blank" rel="noopener">iLicenseMusic</a> grants you all these rights and more, so you can get back to your 2745project quick! Also, if you need more than one song, you're already a subscriber, and can use more music at no additional cost.<p> 2746 2747With iLicenseMusic, you can cancel at any time, though you will no longer have the right to use our music in new projects or new products after you have cancelled.<p> 2748With MoodMixes you can also cancel at any time, but you will need to stop playing our music in your place of business. 2749</ul> 2750 2751If this still fails to convince you, we do offer a buyout option. <a href="http://magnatune.com/info/email_us?to=John" target="_blank" rel="noopener">Email John</a>. 2752 2753<b>How Does The Artist Benefit?</b><p> 2754At Magnatune our artists retain all the rights to their own music. Each one signs a contract with Magnatune that allows us to license their music for commercial use. Unlike our competitors, we split the licensing fee directly with the artists, 50-50. Our artists love it when their work is licensed because it provides them with both income and exposure. 2755 2756<b>What <i>is</i> Music Licensing Anyway?</b><p> 2757Music licensing is all around us. A popular song in a TV commercial or in a movie. The music on the radio. Music in restaurants and even elevators. Basically, whenever music is used in a commercial project or public space, that music has to be licensed for a specific type of use. The rights to use music are bought and sold every day. 2758 2759<b>What About Non-Commercial Projects?</b><p> 2760 2761Magnatune maintains generous policies regarding non-commercial use of our music. 2762To use our music in a non-commercial project, you must first meet the <a href="http://creativecommons.org/licenses/by-nc-sa/1.0/legalcode" target="_blank" rel="noopener">legal requirement</a> for "non-commercial use" 2763as defined by the Creative Commons License which governs all Magnatune music. 2764If you meet these requirements, simply join as a regular paying member, use the music as you like, as long as you abide the Creative Commons <a href="http://creativecommons.org/licenses/by-nc-sa/1.0/legalcode" target="_blank" rel="noopener">by-nc-sa</a> license.` }, 2765 give: { title: "Give away our music", html: `<b>Give 3 Free Copies to Your Friends</b> 2766<p> 2767While other record labels are busy suing their customers for introducing their friends to great music... 2768 <p> 2769At Magnatune, we want you to copy our music for your friends.<p> 2770 2771Yes, at Magnatune you can legally copy any album from your <a href="http://magnatune.com/downloads" target="_blank" rel="noopener">membership</a> downloads to up to 3 of your friends. 2772 2773 <p> 2774 2775Why do we do this? Because we know you've discovered great music through your friends. It's always been this way: from mix tapes, to CDRs to Peer to Peer. People fall in love with new music 2776by being exposed to it by others. It's such an obvious point, and everyone knows the truth of it, yet the music industry has always fought it (despite the fact that you're actually trying to help them!).<p> 2777What, specifically does our policy mean? 2778<ul> 2779<li> When you download an album you can pass the files to up to 3 of your friends. 2780 2781<li> You're also welcome to email the mp3s themselves, or burn a CDR as a way of giving it to your 3 friends. 2782<li> In short, use whatever mechanism you prefer to send them the music! 2783 2784</ul> 2785 2786Why are we doing this? 2787<ul> 2788<li> We know that if you're buying Magnatune music, that you loved what you heard, and chances are you'll want to play the music to others who might like it too. 2789<li> You're going to introduce people to Magnatune's music, why would we want to prevent that? 2790<li> Despite all the "We are not evil", we-want-to-change-the-world stuff that we rant about, we know that in the end it's all about the music, 2791and that people will only visit and buy from us if they find out about it, and think what they hear is amazing. 2792<li> And finally, because we wanted to reward so many of our customers who emailed us asking if it was ok to make a copy for a friend. We always said yes! so we decided to make it official policy. 2793 2794</ul> 2795 2796Aren't you worried I'll abuse this? 2797<ul> 2798<li>No, because you've always had the technical capability to copy anything from Magnatune (since we don't use DRM): we don't believe in copy protection and we think you're honest, otherwise you wouldn't be bothering to read this! 2799<li>Dishonest people can always abuse the system. Rather, we want to reward all the honest people who truly want to do the right thing. 2800<li>If you abuse our generosity, we're not going to break down your door and throw you in jail. We just want you to feel a little guilty about it <grin>. 2801</ul> 2802 2803We're trusting you to do the right thing, and introduce new people to the music you love. <p> 2804You'll feel good about it, your friends will thank you, and you'll help Magnatune prosper.` }, 2805 why: { title: "Why I created Magnatune", html: `<b>Founder's Rant</b><br> 2806 by 2807 John Buckman, founder/owner<p> 2808 2809 Magnatune 2810 was born out of some observations I'd gathered about the music industry, 2811 along with personal experiences from my wife releasing her CD on an 2812 Indie record label. 2813 2814 <p> 2815 2816 <i>Personal experience:</i> 2817 2818 <ul> 2819 2820<li>When my wife was signed to an Indie record label, we were really excited. In the end, she sold close to 1000 CDs, lost all rights to her music for 8 years (even though the CD had been out of print for several years), and earned a little over $100 in royalties (no one is really sure), some of which was paid to her as CD copies of her own C
2820D which she then gave away for promotion. 2821 2822<li>The record label that signed her wasn't evil: they were one of the good guys, and gave her a 70/30 split of the profits (of which there were few). The label got screwed at every turn: distributors refused to carry their CDs unless they spent thousands on useless print ads, or they didn't pay for the CD's sold, etc. In general, all forces colluded to prevent this small, progressive label from succeeding. 2823 2824<li>She was one of the lucky ones. We knew several musicians, signed to various labels, who were also frustrated, who received no money ever and who lost the rights to their music forever. 2825 2826 </ul> 2827 2828 <i>Industry Observations:</i> 2829 2830 <ul> 2831 <li> 2832 Radio is boring: everyone I know is into interesting music, yet good 2833 music is rarely played on the air. I'm into everything from Ambient, 2834 Industrial, Goth, Metal to Renaissance, Baroque, Tango, Indian 2835 Classical and New Age (and many other genres!), and so are many of my 2836 friends. Yet, these genres are barely visible in record stores, and 2837 totally absent from the airwaves. Radio is mostly about Country, Pop, 2838 and Rock, with a little bit of dull, safe classical thrown in. 2839 2840 <li> 2841 CDs cost too much, and artists only get 20 cents to a dollar for each 2842 CD sold. If they're lucky. And, most CDs quickly go out of print: I 2843 buy more CDs from EBay than Amazon. 2844 2845 <li> 2846 Online sales (such as over Amazon.com) often cost the artist 50% of 2847 their already-pathetic royalty (due to a common record contract 2848 provision). International sales and mark-downs often net the artist no 2849 royalties. 2850 2851 <li> 2852 Record labels lock their artists into legal agreements that hold them 2853 for a decade or more. If it's not working out, labels don't print the 2854 band's recordings but nonetheless keep them locked into the contract, 2855 forcing them to produce new albums each year. Even hugely successful 2856 artists often end up owing their record label money. 2857 2858 <li> 2859 Peer to peer software has proven that there's a huge continuing demand for music, and they 2860 want to share it. Lawsuits may shut Bittorrent down, just as Napster was shut down. Clearly 2861 there's a huge public demand for <a href="http://magnatune.com/openmusic" target="_blank" rel="noopener">Open Music</a>. 2862 2863 <li> 2864 Using the Internet to listen to music is usually tedious: there are too 2865 many ads, too many clicks, and the sound quality is usually bad. It's 2866 too much work, not enough reward. A well run Internet radio station solves that, but the entrenched record 2867 industry wants to kill that too, using extremely high fees as the weapon. 2868 2869 <li> 2870 I read <a href="https://www.salon.com/2000/06/14/love_7/" target="_blank" rel="noopener">this 2871 article by Courtney Love</a> six months after starting Magnatune, and 2872 was stunned by how much I have in common with her vision and 2873 understanding of the music business. And, she's much more eloquent 2874 than I am. 2875 2876 </ul> 2877 2878 <i>My solution:</i> 2879 2880 <ul> 2881 2882 <li> 2883 I thought: why not make a record label that has a clue? That helps 2884 artists get exposure, make at least as much money they would make with 2885 traditional labels, and help them get fans and concerts. 2886 2887 <li> 2888 Magnatune is my project. The goal is to find a way to run a record 2889 label in the Internet Reality: file trading, Internet Radio, musicians' 2890 rights, the whole nine-yards. 2891 2892 <li> 2893 If you think Magnatune is a worthy goal, please support it. There are 2894 powerful forces who want it to fail, so I need your help if this is 2895 going to work. 2896 2897 </ul> 2898 2899Magnatune was founded in April 2003, in the People's Republic of <a href="http://en.wikipedia.org/wiki/Berkeley,_California" target="_blank" rel="noopener">Berkeley</a>, California.` }, 2900 podcast: { title: "Podcast", html: `<b>Podcast our music!</b> 2901 2902 <p>If you love music as much as we do, we want you to use our music in your podcasts! 2903 2904<p> 2905 2906Magnatune is one of the only record labels on the planet whose music you can 2907legally use in your non-commercial podcast without a collection society.<p> 2908 2909We specifically grant non-commercial podcasters a waiver to use our music 2910without paying any fee to a royalty collection agency, such as ASCAP, BMI or SoundExchange. 2911Because we work directly with artists, we can legally do this. 2912<p> 2913If your podcast is commercial you can obtain a low-cost <a href="http://ilice
2913nsemusic.com" target="_blank" rel="noopener">commercial license</a> from our other company iLicenseMusic in 2914order to use Magnatune music in your commercial podcast. 2915 2916<p> 2917 2918If your podcast is non-commercial, we ask 2919that you become a <a href="http://magnatune.com/downloads" target="_blank" rel="noopener">download member</a>, and you will then be able to download 2920everything at our site, and 2921can play all our music on your podcast 100% legally without paying collecting society 2922fees. 2923<p> 2924 You can read more about our <a href='/info/licensing/'>music licensing</a>. 2925 2926</ul>` }, 2927 giftcards: { title: "Gift cards", html: `<b>Gift Cards</b><p> 2928 2929A Gift Card is a pre-paid card that lets someone obtain a single album directly, instead of buying a membership.<p> 2930 2931Gift cards are available as a charitable giveaway or as corporate promotion gifts. 2932 2933 <p> 2934 2935 <a href="http://magnatune.com/gift_cards_about?sitePrefix=" target="_blank" rel="noopener">Read more about Gift Cards</a><p> 2936Next steps:<p> 2937 2938 buy an album download with your gift card 2939 look up the current status of your gift card 2940 can't remember your card number? 2941 2942</ul>` }, 2943 terms: { title: "Terms of use", html: `<b>Magnatune Terms of Use</b><p> 2944This is a legal agreement between you, the potential user of this website ("you") 2945and Redmood, Inc. DBA Magnatune ("Magnatune"). Your use of this website ("Site") or any of the 2946products and services offered on this Site, as well as mobile applications which provide 2947access to the products and services on this Site (collectively, the "Services") is 2948conditioned on your acceptance without modification of these Terms of Service ("Terms"). 2949<p> 2950Please read these Terms carefully. If you do not agree to these Terms, 2951you should not use the Site or Services. Magnatune may at its sole discretion 2952modify these Terms at any time and such modifications will be effective 2953immediately upon being posted on this Site. The most current version of these 2954Terms can be accessed through the "Terms" link in the "About" section of the 2955Site. You agree to review these Terms periodically, and your continued use of 2956the Site and Services after the posting of modifications constitutes your 2957affirmative agreement to abide and be bound by the Terms, as modified. 2958 2959<p> 2960<b>Changes to the Site.</b> 2961<p> 2962Magnatune reserves the right to modify or 2963discontinue any of the Services and material displayed on or offered through the 2964Site, with or without notice to you, and is not obligated to support or update 2965the Site or Services. 2966<p> 2967<b>Eligibility.</b> 2968<p> 2969Use of the Site and Services is void 2970where prohibited. You shall comply with all applicable local, state, federal and 2971national laws, statutes, ordinances and regulations regarding the use of the 2972Site and Services. You represent and warrant that all registration information 2973you submit is truthful and accurate and that you agree to maintain the accuracy 2974of such information. You must be at least 18 years of age and have Internet 2975access, a valid e-mail address, and a valid shipping address to register for the 2976Site and Services. Full use of the Site and Services may require you to provide 2977a valid credit card. While individuals between the ages of 13 and 18 may utilize 2978the Site and Services, they may do so only with the involvement of a parent or 2979legal guardian. If you are 13 or older but under the age of 18, you should 2980review these Terms with your parent or guardian to make sure that you and your 2981parent or guardian understand these Terms. 2982<p> 2983<b>
2983Registration and Account Rules.</b> 2984<p> 2985To register, you must provide your name, e-mail address and payment details. 2986You shall permit Magnatune to store and use this personal data provided by you 2987for purposes of maintaining your account. In addition, you agree to comply with the following terms: 2988 2989<li> 2990You shall maintain your account with up-to-date information; 2991<p> 2992<li> 2993You shall be responsible for maintaining the confidentiality and security of 2994your account; 2995<p> 2996<li> 2997You shall immediately report any unauthorized use of your account to Magnatune, 2998as well as any other breach of security; 2999<p> 3000<li> 3001You shall not share your password with any other person outside of your family household; and 3002<p> 3003<li> 3004You shall not assign, sell or transfer your account to any other person. 3005<p> 3006<b>Fees and Charges.</b> 3007<p> 3008Magnatune provides both a free version of the Service, as well as additional features and benefits to 3009paid members. The cost to join as a paid member depend on a number of factors, such as which services 3010are being purchased, your country, the currency you are paying in, and are also liable to change 3011from time to time. Our current fee schedule is published on the Site. 3012<p> 3013<b>Cancellation.</b> 3014<p> 3015Magnatune reserves the right to terminate or suspend your access to the Services 3016or Site, with or without cause, at any time and effective immediately. Reasons 3017Magnatune may terminate or suspend your account shall include, but are not 3018limited to, the violation of any of these Terms. 3019<p> 3020You may cancel your Magnatune account at anytime, and cancellation will be 3021effective immediately. You acknowledge, however, that cancellation will result 3022in your forfeiture of any access to the Services. 3023To cancel your account, you can contact us by using the "Contact us" link in the "About" 3024section of the Site.<p> 3025<b>Privacy Policy.</b> 3026<p> 3027Magnatune's personal information practices on the Site and Services are 3028governed by Magnatune's Privacy Policy. 3029 3030<p> 3031<b>Cookies.</b> 3032<p> 3033Magnatune may place a cookie or other small text file on any computer from which 3034you access the Site or Services and you consent to that placement. 3035<p> 3036<b>Consent to Receive E-mail.</b> 3037<p> 3038By using the Site and Services, you consent to receiving e-mail from Magnatune. 3039These e-mailings will include, but are not limited to, notices about your 3040account (for example, shipping and receiving e-mails) and information concerning 3041or related to the Site or Services, such as recommendations or notifications of 3042posted blurbs. These e-mailings are part of your relationship with Magnatune. 3043<p> 3044<b>Use of Information Submitted.</b> 3045<p> 3046You acknowledge and agree that any ratings, reviews, comments, feedback, 3047suggestions, pictures and other materials (collectively, the "Content") you 3048contribute to the Site shall become Magnatune's property and you hereby assign 3049all rights, title and interests in and to such contributions to Magnatune. 3050Magnatune is hereby granted a non-exclusive, worldwide, royalty-free, 3051transferable right to fully exploit such Content (including all related 3052intellectual property rights). Magnatune does not regularly review the content 3053posted on the Site; however, Magnatune reserves the right to remove or edit any 3054Content from the Site at any time without notice. 3055 3056<p> 3057<b>Your Conduct.</b> 3058<p> 3059You warrant, represent and agree that you will not contribute any Content that 3060infringes or violates the rights of any other party or violates any laws, 3061contributes to or encourages infringing or otherwise unlawful conduct, or is 3062obscene or in poor taste. You agree that use of the Site and Services is subject 3063to all applicable national, federal, state, and local laws and regulations, and 3064that you are solely responsible for the contents of your communications through 3065the Site and Services. 3066<p> 3067<b>Objectionable Material.</b> 3068<p> 3069You understand that by using the Services you may encounter content that may be 3070deemed objectionable, obscene or in poor taste, which content may or may not be 3071identified as having explicit language. You agree to use the Services at your 3072own risk and that Magnatune shall have no liability to you for content that you 3073may find objectionable, obscene or in poor taste. 3074<p> 3075<b>Intellectual Property.</b> 3076<p> 3077You agree that the Site and Services, including but not limited to graphics, 3078audio clips, and editorial content, contain proprietary information and material
3079that is owned by Magnatune and/or its licensors, and is protected by applicable 3080intellectual property and other laws, including but not limited to copyright, 3081and that you will not use such proprietary information or materials in any way 3082whatsoever except for use of the Site and Services in compliance with these 3083Terms. No portion of the Site or Services may be reproduced in any form or by 3084any means, unless previous written permission is obtained, except where said 3085content is explicitly marked with a license authorizing it, such as a Creative 3086Commons license. You agree not to 3087modify, rent, lease, loan, sell, distribute or create derivative works based on 3088the Site or Services in any unauthorized way whatsoever, except as explicitly 3089permitted by a license authorizing it, such as on the API section of the Service, 3090or as permitted by a Creative Commons license that has been explicitly assigned 3091to the specific contents. 3092<p> 3093The trademarks, logos and service marks (collectively, the "trademarks") 3094displayed on the Site or Services, unless otherwise specified, are the 3095registered and unregistered trademarks of Magnatune or their respective owners. 3096Nothing contained on the Site or Services shall be construed as granting, by 3097implication or otherwise, any license or right to use any trademark displayed on 3098the Site or Services without the express written permission of the trademark 3099owner. 3100<p> 3101<b>Claims of Copyright Infringement.</b> 3102<p> 3103Magnatune respects the intellectual property of others, and we ask you to do the 3104same. If you believe that your work has been copied in a way that constitutes 3105copyright infringement, please provide our Copyright Agent with the following 3106information: 3107<p> 3108<li> 3109An electronic or physical signature of the person authorized to act on behalf of 3110the owner of the copyright interest; 3111<p> 3112<li> 3113A description of the copyrighted work that you claim has been infringed; 3114<p> 3115<li> 3116A description of where the material that you claim is infringing is located on 3117the Site; 3118<p> 3119<li> 3120Your address, telephone number, and e-mail address; 3121<p> 3122<li> 3123A statement by you that you have a good faith belief that the disputed use is 3124not authorized by the copyright owner, its agent, or the law; and 3125<p> 3126<li> 3127A statement by you, made under penalty of perjury, that the above information in 3128your notice is accurate and that you are the copyright owner or authorized to 3129act on the copyright owner's behalf. 3130<p> 3131Magnatune's Copyright Agent for notice of claims of copyright infringement can 3132be reached as follows: Copyright Agent, Magnatune, 454 Las Gallinas Ave #142, San Rafael, CA 94903. 3133<p> 3134<b>Software Available on the Site</b> 3135 3136<p> 3137All software that is made available on the Site is the copyrighted work of 3138Magnatune and/or its suppliers and is licensed, not sold. Your use of any such 3139software is governed by the terms of the license agreement, if any, which 3140accompanies or is included with the software. You may not install or use any 3141software that is accompanied by or includes a license agreement unless you first 3142agree to the terms of the license agreement. For any software not accompanied by 3143a license agreement, Magnatune hereby grants you a non-exclusive, revocable, 3144personal, non-transferable license to use the software solely in connection with 3145the Site and Services in accordance with these Terms. 3146<p> 3147You will not disassemble, decompile or reverse engineer the software. You 3148acknowledge that the software and any accompanying documentation is subject to 3149applicable export laws and regulations of the United States. You agree not to 3150export or re-export the software, directly or indirectly, to any countries that 3151are subject to United States export restrictions. 3152<p> 3153 3154Your license to use the software terminates upon the termination of your access 3155to the Site or Services. Your license to the software may also terminate if 3156Magnatune, in its sole discretion, modifies the Site or Services to no longer 3157support the software. 3158<p> 3159<b>Disclaimers of Warranties.</b> 3160<p> 3161THIS SITE, ITS CONTENTS, AND ANY SOFTWARE, PRODUCTS, AND SERVICES OFFERED OR 3162CONTAINED HEREIN ARE PROVIDED ON AN "AS IS" BASIS AND ON AN "AS AVAILABLE" BASIS. 3163MAGNATUNE MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND WITH RESPECT TO 3164THIS SITE, ITS CONTENTS, OR SUCH SOFTWARE PRODUCTS AND SERVICES, AND DISCLAIMS 3165ALL SUCH REPRESENTATIONS AND WARRANTIES, INCLUDING, WITHOUT LIMITATION, 3166WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT 3167OF THIRD PARTY RIGHTS, ERROR-FREE OR UNINTERRUPTED SERVICE, ACCURACY, 3168AVAILABILITY, RELIABILITY, SECURITY, CURRENCY AND COMPLETENESS ARISING FROM OR 3169RELATING TO THIS SITE, ITS CONTENT OR ANY LINKS, ANY SOFTWARE, TOOLS, TIPS, 3170PRODUCTS OR SERVICES PROVIDED THROUGH THIS SITE. IN ADDITION, MAGNATUNE DOES NOT 3171REPRESENT OR WARRANT THAT THE INFORMATION ACCESSIBLE VIA THIS SITE IS ACCURATE, 3172COMPLETE OR CURRENT. ALL INFORMATION IS SUBJECT TO CHANGE WITHOUT NOTICE. 3173 3174<p> 3175<b>Limitations on Liability.</b> 3176<p> 3177IN NO EVENT SHALL MAGNATUNE, ANY OFFICER, AFFILIATE, DIRECTOR, SHAREHOLDER, 3178AGENT, CONTRACTOR OR EMPLOYEE OF MAGNATUNE BE LIABLE FOR ANY DIRECT, INDIRECT, 3179SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR AGGRAVATED DAMAGES (
3179INCLUDING 3180WITHOUT LIMITATION DAMAGES FOR LOSS OF DATA, INCOME OR PROFIT, LOSS OF OR DAMAGE 3181TO PROPERTY AND THIRD PARTY CLAIMS) OR ANY OTHER DAMAGES OF ANY KIND, ARISING 3182OUT OF OR IN CONNECTION WITH: THIS SITE; ANY MATERIALS, INFORMATION, 3183QUALIFICATION AND RECOMMENDATIONS APPEARING ON THIS SITE; ANY SOFTWARE, TOOLS, 3184TIPS, PRODUCTS, OR SERVICES OFFERED THROUGH, CONTAINED IN OR ADVERTISED ON THIS 3185SITE; ANY LINK PROVIDED ON THIS SITE; AND YOUR ACCOUNT AND PASSWORD, WHETHER OR 3186NOT MAGNATUNE HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. THIS 3187EXCLUSION OF LIABILITY SHALL APPLY TO THE FULLEST EXTENT PERMITTED BY LAW. THIS 3188PROVISION SHALL SURVIVE THE TERMINATION OF YOUR RIGHT TO USE THIS SITE. YOU 3189ACKNOWLEDGE THAT YOU WILL BE FULLY LIABLE FOR ALL DAMAGES RESULTING DIRECTLY OR 3190INDIRECTLY FROM YOUR USE OF THIS SITE. 3191<p> 3192<b>User Disputes.</b> 3193<p> 3194You are solely responsible for your interactions with other Magnatune users. 3195Magnatune reserves the right, but has no obligation, to monitor disputes between 3196you and other users. 3197<p> 3198<b>Electronic Signature and Contracts.</b> 3199<p> 3200YOU ACKNOWLEDGE THAT YOUR USE OF THE SITE OR SERVICES CONSTITUTE YOUR AGREEMENT AND 3201INTENT TO BE BOUND BY SUCH AGREEMENTS. YOUR AGREEMENT AND INTENT TO BE BOUND BY 3202YOUR USE OF THE SITE OR SERVICES APPLIES TO ALL RECORDS RELATING TO ALL TRANSACTIONS YOU 3203ENTER INTO ON THE SITE, INCLUDING NOTICES OF CANCELLATION, POLICIES, CONTRACTS 3204AND APPLICATIONS. 3205<p> 3206<b>Governing Law.</b> 3207<p> 3208The laws of the State of California, excluding its conflicts of law rules, 3209govern these Terms and your use of the Site and Services. Your use of the Site 3210and Services may also be subject to other local, state or federal laws. You 3211expressly agree that exclusive jurisdiction for any claim or dispute with 3212Magnatune relating in any way to your use of the Site or Services resides in the 3213courts of the State of California. 3214<p> 3215<b>Notices.</b> 3216<p> 3217Magnatune may send you notice with respect to the Site or Services by sending an 3218e-mail message to the e-mail address listed in your account information, by 3219sending a letter via postal mail to the shipping address listed in your account 3220information, or by a posting on the Site. Notices shall become effective 3221immediately when posted to the Site and upon delivery when sent by e-mail or 3222postal mail. 3223 3224<p> 3225<b>Miscellaneous.</b> 3226<p> 3227These Terms constitute the entire agreement between you and Magnatune and govern 3228your use of the Site and Services, superseding any prior agreements between you 3229and Magnatune. You may also be subject to additional terms and conditions that 3230may apply when you use third-party content or third party-software. If any part 3231of these Terms is held invalid or unenforceable, that portion shall be construed 3232in a manner consistent with applicable law to reflect, as nearly as possible, 3233the original intentions of the parties, and the remaining portions shall remain 3234in full force and effect. Magnatune's failure to enforce any right or provision 3235in these Terms will not constitute a waiver of such provision, or any other 3236provision of these Terms. If any provision of these Terms is found by a court of 3237competent jurisdiction to be invalid, the other provisions will remain in full 3238force and effect. Magnatune will not be responsible for failures to fulfill any 3239obligations due to causes beyond its control. No agency, partnership, joint 3240venture or employment is created as a result of these Terms. All provisions of 3241these Terms which by their nature should survive termination will survive 3242termination, including, without limitation, ownership provisions, warranty 3243disclaimers, and limitations of liability.` }, 3244 api: { title: "APIs, RSS & XML", html: `<a name="xml"> 3245 <b>Data Feeds in XML, CSV and SQL</b> 3246 3247 <p> 3248 3249 If you want to integrate Magnatune's Creative Commons' by-nc-sa v1.0 licensed music into your 3250 web site or application, these links will help you. 3251 <p> 3252 3253 <b>WE HIGHLY RECOMMEND YOU USE <a href="http://magnatune.com/info/sqlite-normalized" target="_blank" rel="noopener">THIS SQLITE DATABASE</a> for your data feed, as the others are all being fazed out and do not have all the metadata that this database file has.</b> 3254<p> 3255 3256 If you're wondering how we'd feel about a specific use of 3257 Magnatune's music, feel free to <a href="http://magnatune.com/contact" target="_blank" rel="noopener">contact us</a>
3257 and tell us your thoughts. 3258 <hr> 3259 Below are the data feeds which can help you. These are continuously updated with the latest data. <a href="http://magnatune.com/contact" target="_blank" rel="noopener">Contact us</a> if you need something special; we're happy to 3260oblige. 3261 <ul> 3262 <li> 3263 <a href="http://he3.magnatune.com/info/album_info.xml" target="_blank" rel="noopener">Albums and songs XML file</a> Every album and song 3264 described, with each song grouped into the album it belongs to. This provides 3265 more information, and is useful for uses that are more album oriented.<br> 3266 This file is also available in a number of compressed formats: 3267 <a href="http://he3.magnatune.com/info/album_info_xml.zip" target="_blank" rel="noopener">zip</a>, <a href="http://he3.magnatune.com/info/album_info_xml.gz" target="_blank" rel="noopener">gzip</a>, <a href="http://he3.magnatune.com/info/album_info_xml.bz2" target="_blank" rel="noopener">bzip2</a>.<br><br> 3268 <li> 3269 <a href="http://magnatune.com/info/sqlite" target="_blank" rel="noopener">SQLite Export</a>: a small database in SQLite format.<br><br> 3270 <li> 3271 <a href="http://magnatune.com/info/sqlite-normalized" target="_blank" rel="noopener">SQLite Normalized Export</a>: a fully normalized small database in SQLite format.<br><br> 3272 <li> 3273 <a href="http://he3.magnatune.com/info/song_info.sql" target="_blank" rel="noopener">Song-by-song MYSQL table dump</a>. Every 128k mp3 at Magnatune 3274 described, with each mp3 file (ie, each song) fully described, loadable directly into 3275 your MYSQL database. Creates a "magnatune_song_info" table.<br><br> 3276 <li> 3277 <a href="http://he3.magnatune.com/info/song_info.xml" target="_blank" rel="noopener">Song-by-song XML file</a>. Every 128k mp3 at Magnatune 3278 described, with each mp3 file (ie, each song) fully described. This file is for 3279 uses that are more single-mp3 oriented. A <a href="http://he3.magnatune.com/info/song_info2.xml" target="_blank" rel="noopener">simplified 3280 version</a> of this file is also available. <br> 3281 These files are also available in a number of compressed formats: <br> 3282 Song-by-song XML file: <a http://he3.magnatune.com/info/song_info_xml.zip">zip</a>, <a href="http://he3.magnatune.com/info/song_info_xml.gz" target="_blank" rel="noopener">gzip</a>, <a href="http://he3.magnatune.com/info/song_info_xml.bz2" target="_blank" rel="noopener">bzip2</a><br> 3283 simplified version: <a href="http://he3.magnatune.com/info/song_info2_xml.zip" target="_blank" rel="noopener">zip</a>, <a href="http://he3.magnatune.com/info/song_info2_xml.gz" target="_blank" rel="noopener">gzip</a>, <a href="http://he3.magnatune.com/info/song_info2_xml.bz2" target="_blank" rel="noopener">bzip2</a><br> 3284 Also slim version (without artistbio): <a href="http://he3.magnatune.com/info/song_info_slim.xml" target="_blank" rel="noopener">xml</a>, <a href="http://he3.magnatune.com/info/song_info_slim_xml.zip" target="_blank" rel="noopener">zip</a>, <a href="http://he3.magnatune.com/info/song_info_xml_slim.gz" target="_blank" rel="noopener">gzip</a>, <a href="http://he3.magnatune.com/info/song_info_xml_slim.bz2" target="_blank" rel="noopener">bzip2</a>. 3285 3286 <br> 3287 <li> 3288 <a href="http://he3.magnatune.com/info/song_info.csv" target="_blank" rel="noopener">Song-by-song CSV file</a>. Every 128k mp3 at Magnatune 3289 described, with each mp3 file (ie, each song) fully described, the comma-separated-value 3290 (CSV) format which imports directly into most database programs and Microsoft 3291 Excel.<br> 3292 This file is also available in a number of compressed formats: 3293 <a href="http://he3.magnatune.com/info/song_info_csv.zip" target="_blank" rel="noopener">zip</a>, <a href="http://he3.magnatune.com/info/song_info_csv.gz" target="_blank" rel="noopener">gzip</a>, <a href="http://he3.magnatune.com/info/song_info_csv.bz2" target="_blank" rel="noopener">bzip2</a><br><br> 3294 </ul> 3295 To keep these downloads small, we reccomend using the bzip2 files if possible. 3296 3297<a name="purchase"><p> 3298<b>Memberships</b> 3299 3300 <p> 3301 Magnatune.com offers memberships that lets users enjoy additional benefits. Users can stream the entire Magnatune.com catalog with no speaker interruption at the end at each track and listen to ogg streams in higher quality. The download membership lets users download an unlimited amount of albums at no additional charge. While memberships has to be created at the Magnatune.com site, it is possible to use the memberships from other applications. <p> 3302 3303To play a membership stream with no speaker interruption, the standard stream url should be modified to point at the correct membership sub-site. Authentication can either be handleded by imnbedding the membership username and password in the url, or by handeling sessions. Also, all speechless streams has _nospeech appended to the filename. 3304<p> 3305for instance, to transform the url <br> 3306http://he3.magnatune.com/all/03--Massassi%20Morning%20Star--Ishwish.mp3"<p>to a valid stream membership url, could look like this 3307<p> 3308http://username:[email protected]/all/03--Massassi%20Morning%20Star--Ishwish_nospeech.mp3 3309<p> 3310The steps neeed for this transformation are: 3311<p> 3312<ul> 3313<li> "http://he3" should be replaced with "http://username:[email protected]". If your programming language's HTTP API doesn't support inline username/passwords in the URL, consult your HTTP API document for how to do HTTP AUTH queries. 3314<li> ".mp3" or ".ogg" should be replaced with "_nospeech.mp3" or "_nospeech.ogg" respectively. 3315</ul> 3316To download albums without having to pay for each one uses an API very similar to the one for making credit card purchases. Both the request and the resulting xml reply are modelled on the purchase API.<p> 3317 3318To let a download member download an album for free, the following url should be used: <br> 3319<a href="http://download.magnatune.com/buy/membership_free_dl_xml" target="_blank" rel="noopener">http://download.magnatune.com/buy/membership_free_dl_xml</a><p> 3320 3321This is a password protected url, so either you will need to handle logging in or pass the username and password in the url.<p> 3322 3323The following fields are required:<p> 3324 3325<ul> 3326<li>sku= field, which is the album's sku code 3327<li>
3327id= field, which is your partner id. <a href="http://magnatune.com/info/email_us?to=john&subject=sales+partner+id" target="_blank" rel="noopener">Contact john</a> to obtain one (there is no cost). 3328</ul> 3329 3330This returns an xml reply that contains the download information for the different formats, simmilar to the reply from a purchase request. <p> 3331 3332As an example, this i how this url is constructed in Amarok 2: <br><br> 3333QString purchaseURL = "http://" + username + ":" + password + "@" + membershipType + ".magnatune.com/buy/membership_free_dl_xml?sku=" + m_currentAlbum->albumCode() + "&id=myId";<p> 3334<b>Detecting new releases</b> 3335 <p> 3336To detect whether we have any new releases, please download this file:<p> 3337<a href="http://magnatune.com/info/changed.txt" target="_blank" rel="noopener">http://magnatune.com/info/changed.txt</a><p> 3338 3339The changed.txt file contains a single number, which is a CRC of all our releases. When that number changes, this means that all the other data feeds also have new data. You can check this changed.txt file as often as you like, but we recommend no more frequently than every 24h.<p> 3340<b>Embedded Music Purchasing</b> 3341 3342 <p> 3343 Music Purchasing through the API is no longer supported. Apologies for this, but our VISA credit card processor really didn't like this, as it was thoroughly NOT "PCI Compliant" for us to allow this.` }, 3344 contact: { title: "Contact Magnatune", html: `<b>How to contact Magnatune</b> 3345 <p> 3346We are based in San Rafael, California, which is 15 miles north of San Francisco.<p> 3347<b>email:</b><p><a href="http://a.magnatune.com/support/submit" target="_blank" rel="noopener">Click here</a> to send us email.<p> 3348<b>postal mail:</b><p> 3349Magnatune<br> 3350454 Las Gallinas Ave, #142<br> 3351San Rafael, CA 94903<br> 3352 USA 3353 <p> 3354 <b>We are not currently accepting submissions.</b>` }, 3355}; 3356// Mirror of the server's m3_selfhost_html: make in-app doc pages fully self-hosted 3357// (retired he3 asset host + magnatune's own catalog/nav hosts -> same-origin), so 3358// nothing here links out to an external magnatune box and "he3" is never shown. 3359// Genuinely external sites (blogs./my./a. magnatune subdomains, creativecommons, 3360// wikipedia, â¦) are left untouched. Steps run in order (longest hosts first) so a 3361// full he3 URL collapses to same-origin before the bare "http://he3" prose token. 3362function selfhostHTML(s) { 3363 return s 3364 .split("http://he3.magnatune.com").join("") 3365 .split("https://he3.magnatune.com").join("") 3366 .split("http://username:[email protected]/all/").join("/all/") 3367 .split("http://username:[email protected]").join("your member login on this server") 3368 .split("http://download.magnatune.com/all/").join("/all/") 3369 .split("http://download.magnatune.com/music/").join("/music/") 3370 .split("http://download.magnatune.com/buy/").join("/buy/") 3371 .split("http://magnatune.com/").join("/") 3372 .split("https://magnatune.com/").join("/") 3373 .split("http://www.magnatune.com/").join("/") 3374 .split("http://magnatune.com").join("/") 3375 .split("http://he3").join("this server"); 3376} 3377function renderInfo(c, key) { 3378 const pg = INFO_PAGES[key]; 3379 if (!pg) { c.innerHTML = `<div class="pad">Page not found.</div>`; return; } 3380 c.innerHTML = `<div class="page-title">${esc(pg.title)}</div><div class="help">${selfhostHTML(pg.html)}</div>`; 3381} 3382 3383function renderHelp(c) { c.innerHTML = `<div class="page-title">Help</div>` + linkifyHelp(selfhostHTML(HELP_HTML)); } 3384
3385/* ===================== Magnatune for iOS (SideStep sideload guide) ===================== 3386 * Static how-to page. Route token "ios" (path /player/ios), linked from Settings -> Apps. 3387 * Explains installing the native iOS app via the Mac app SideStep -> Search AltStore -> "magnatune". 3388 * Screenshots live in /app/ios/. Styles are inlined so this stands alone in magnatune.js. */ 3389function renderIosInstall(c) { 3390 c.innerHTML = `<div class="page-title">Magnatune for iOS (iPhone & iPad)</div> 3391 <div class="help ios-install"> 3392 <style> 3393 .ios-install{max-width:820px;} 3394 .ios-install h2{margin:30px 0 6px;font-size:17px;} 3395 .ios-install p{line-height:1.55;} 3396 .ios-install figure{margin:14px 0 4px;} 3397 .ios-install img{display:block;max-width:100%;height:auto;border:1px solid rgba(128,128,128,.28);border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.14);} 3398 .ios-install .win img{max-width:min(100%,480px);} 3399 .ios-install figcaption{margin-top:8px;font-size:13px;opacity:.72;} 3400 .ios-shots{display:flex;flex-wrap:wrap;gap:22px;align-items:flex-start;margin-top:10px;} 3401 .ios-shots figure{flex:0 1 auto;margin-top:0;} 3402 .ios-shots .phone img{max-width:min(100%,300px);} 3403 .ios-shots .pad img{max-width:min(100%,560px);} 3404 .ios-install .ios-dl{display:inline-flex;align-items:center;gap:9px;margin:10px 0 2px;padding:11px 20px;border-radius:10px;background:#1667c4;color:#fff;font-weight:600;text-decoration:none;} 3405 .ios-install .ios-dl:hover{background:#12559f;} 3406 .ios-install .dl-sub{font-size:12.5px;opacity:.62;margin:6px 0 0;} 3407 .ios-install .ios-note{margin:28px 0 0;padding:14px 16px;border:1px solid rgba(128,128,128,.28);border-radius:12px;font-size:14px;line-height:1.5;} 3408 .ios-install .ios-note b{display:block;margin-bottom:4px;} 3409 .ios-install .ios-oneclick-box{background:rgba(22,103,196,.06);border:1px solid rgba(22,103,196,.22);border-radius:12px;padding:2px 18px 14px;margin:6px 0;} 3410 .ios-install .manual-divider{margin:34px 0 0;padding-top:18px;border-top:1px solid rgba(128,128,128,.22);font-weight:600;} 3411 </style> 3412 <p>There's a native <b>Magnatune player for iPhone and iPad</b>. It isn't on the App Store â instead you install it for free using <b>SideStep</b>, a small Mac app that makes side-loading apps onto an iPhone or iPad about as easy as it is on Android. The first-time setup takes a few minutes; after that, updates are automatic.</p> 3413 3414 <div class="ios-oneclick-box"> 3415 <h2>Fastest: one-click installer</h2> 3416 <p>On your <b>Mac</b>, click below and double-click the downloaded installer. It sets up SideStep, walks you through connecting your iPhone or iPad over USB, and installs Magnatune â then keeps it refreshed. You'll need a Mac and a USB cable.</p> 3417 <p><a id="ios-oneclick" class="ios-dl" href="https://github.com/johnbuckman/magnatune-app/releases/latest/download/magnatune-app-installer.zip" onclick="return MAG.iosInstall(event)"><i class="fa-solid fa-wand-magic-sparkles"></i> Install Magnatune on iPhone / iPad</a></p> 3418 <p id="ios-oneclick-note" class="dl-sub"></p> 3419 <div id="ios-fallback" hidden> 3420 <p class="dl-sub">Don't have SideStep yet? <a href="https://github.com/johnbuckman/magnatune-app/releases/latest/download/magnatune-app-installer.zip" download>Download the Magnatune installer</a> â double-click it and follow the prompts; it installs SideStep for you.</p> 3421 </div> 3422 </div> 3423 3424 <p class="manual-divider">Prefer to set it up by hand? The steps below do the same thing manually.</p> 3425 3426 <h2>1. Get SideStep for your Mac</h2> 3427 <p>Download the latest release of SideStep, open the <b>.dmg</b>, and drag SideStep into your Applications folder.</p> 3428 <p><a class="ios-dl" href="https://github.com/johnbuckman/SideStep/releases/download/v0.2.4/SideStep-0.2.4.dmg" download><i class="fa-solid fa-download"></i> Download SideStep 0.2.4 (.dmg)</a></p> 3429 <p class="dl-sub">macOS · 9.5 MB · or browse <a href="https://github.com/johnbuckman/SideStep/releases" target="_blank" rel="noopener">all SideStep releases</a>.</p> 3430 3431 <h2>2. Run SideStep and sign in</h2> 3432 <p>Launch SideStep and add an Apple account. Any Apple ID works â you can even make a new, free one at <
3432a href="https://icloud.com" target="_blank" rel="noopener">iCloud.com</a>. (A free account can side-load up to 3 apps; you can add several free logins â see the note below.)</p> 3433 <figure class="win"><img src="ios/sidestep.jpg" alt="The SideStep app running on macOS" loading="lazy"><figcaption>SideStep on your Mac.</figcaption></figure> 3434 3435 <h2>3. Connect your iPhone or iPad</h2> 3436 <p>Plug the device into your Mac with a cable. If it asks, tap <b>Trust This Computer</b> on the device and enter your passcode. (After the first install you can add and refresh apps over Wi-Fi â see the note below.)</p> 3437 3438 <h2>4. Enable Developer Mode on your device</h2> 3439 <p>On the iPhone or iPad, go to <b>Settings â Privacy & Security â Developer Mode</b> (near the bottom) and turn it on. The device asks you to <b>restart</b>; after it reboots, swipe up and tap <b>Turn On</b> to confirm. Apple's guide: <a href="https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device" target="_blank" rel="noopener">Enabling Developer Mode on a device</a>.</p> 3440 3441 <h2>5. Install Magnatune via AltStore</h2> 3442 <p>In SideStep, click <b>Search AltStore</b>, type <b>magnatune</b> into the search box, and click <b>Install</b> next to Magnatune. SideStep installs it onto your device and keeps it refreshed automatically.</p> 3443 <figure class="win"><img src="ios/altstore-search.jpg" alt="Searching AltStore for magnatune inside SideStep" loading="lazy"><figcaption>Search AltStore for “magnatune”, then click <b>Install</b>.</figcaption></figure> 3444 3445 <h2>6. Approve the app on your device</h2> 3446 <p>Apps installed with a free Apple account must be trusted once before they'll open. On the device, go to <b>Settings â General â VPN & Device Management</b>, tap your Apple ID under <b>Developer App</b>, then tap <b>Trust</b> / <b>Approve</b>.</p> 3447 3448 <h2>7. Open Magnatune</h2> 3449 <p>Launch Magnatune from your Home Screen and sign in with your Magnatune membership (in the app's Settings) to stream and download the full catalog without the spoken announcement.</p> 3450 <div class="ios-shots"> 3451 <figure class="phone"><img src="ios/iphone.jpg" alt="Magnatune running on an iPhone" loading="lazy"><figcaption>Magnatune on iPhone</figcaption></figure> 3452 <figure class="pad"><img src="ios/ipad.jpg" alt="Magnatune running on an iPad" loading="lazy"><figcaption>Magnatune on iPad</figcaption></figure> 3453 </div> 3454 3455 <div class="ios-note"><b>Good to know</b> 3456 Once set up, you don't need to keep the device plugged in â as long as it's on and awake, SideStep can install and refresh apps over Wi-Fi. Free Apple accounts are limited to 3 side-loaded apps each, but you can add as many free Apple logins to SideStep as you like.</div> 3457 </div>`; 3458} 3459 3460// One-click iOS install. On a Mac, try to hand off to an already-installed 3461// SideStep via sidestep://install?repo=johnbuckman/magnatune-app; if nothing 3462// takes over within ~1.5s (SideStep isn't installed), reveal the installer 3463// download â the installer sets SideStep up too. SideStep is a Mac app, so off a 3464// Mac (including on the iPhone/iPad itself) we don't hand off; we explain a Mac is 3465// needed and show the download. iPadOS Safari poses as a Mac (platform 3466// "MacIntel") but reports touch, so a real Mac = Mac-like AND non-touch. If JS is 3467// off, the link falls through to downloading the installer .zip directly. 3468MAG.iosInstall = (e) => { 3469 e.preventDefault(); 3470 const note = el("ios-oneclick-note"), fb = el("ios-fallback"); 3471 const macLike = /Mac/.test(navigator.platform || navigator.userAgent || ""); 3472 const onMac = macLike && (navigator.maxTouchPoints || 0) <= 1; 3473 if (!onMac) { 3474 if (note) note.textContent = "Installing needs a Mac with a USB cable â open this page on your Mac, then click here."; 3475 if (fb) fb.hidden = false; 3476 return false; 3477 } 3478 let handled = false; 3479 const onhide = () => { handled = true; }; 3480 document.addEventListener("visibilitychange", onhide, { once: true }); 3481 window.addEventListener("blur", onhide, { once: true }); 3482 window.location.href = "sidestep://install?repo=johnbuckman/magnatune-app"; 3483 setTimeout(() => { 3484 document.removeEventListener("visibilitychange", onhide); 3485 window.removeEventListener("blur", onhide); 3486 if (!handled && fb) { 3487 fb.hidden = false;
3488 if (note) note.textContent = "SideStep didn't open â install it with the link below (it also installs the app)."; 3489 } 3490 }, 1500); 3491 return false; 3492}; 3493 3494/* ===================== catalog text-format importer ===================== 3495 * The app keeps ONE in-memory sql.js database with the schema below â the same 3496 * tables/columns the queries have always used. It is rebuilt from compact text 3497 * downloads (not a .db), in two waves: 3498 * wave 1 (MTCORE1) -> importCatalog(): every row except album descriptions / 3499 * artist bios. songs.mp3 is rebuilt from the per-album stem (+ rare override). 3500 * wave 2 (MTDESC1) -> importDescriptions(): UPDATEs description/bio into rows. 3501 * Columns no query reads (homepage/photo/society/upc/itunes_buy_url, subgenres) 3502 * are simply absent. A new catalog version reimports from scratch into a fresh 3503 * Database (clean slate â no stale rows), then hot-swaps. See CATALOG_URL. */ 3504const CORE_SCHEMA = ` 3505CREATE TABLE artists(artists_id INTEGER PRIMARY KEY, name TEXT, description TEXT, bio TEXT, page TEXT, city TEXT, state TEXT, country TEXT); 3506CREATE TABLE albums(album_id INTEGER PRIMARY KEY, artist_id INTEGER, name TEXT, description TEXT, sku TEXT, release_date INTEGER, popularity INTEGER); 3507CREATE TABLE genres(genre_id INTEGER PRIMARY KEY, name TEXT); 3508CREATE TABLE collections(collections_id INTEGER PRIMARY KEY, name TEXT); 3509CREATE TABLE genres_albums(genre_id INTEGER, album_id INTEGER); 3510CREATE TABLE collections_albums(collection_id INTEGER, album_id INTEGER); 3511CREATE TABLE songs(song_id INTEGER PRIMARY KEY, album_id INTEGER, name TEXT, track_no INTEGER, duration INTEGER, mp3 TEXT); 3512CREATE TABLE playlists(playlist_id INTEGER PRIMARY KEY, sort_order INTEGER, owner_id INTEGER, name TEXT); 3513CREATE TABLE playlist_songs(playlist_id INTEGER, sort_order INTEGER, song_id INTEGER); 3514CREATE TABLE recommendations(album_id INTEGER, recommended_album_id INTEGER, rank INTEGER);`; 3515const CORE_INDEXES = ` 3516CREATE INDEX albums_artist_id ON albums(artist_id); 3517CREATE INDEX songs_album_id ON songs(album_id); 3518CREATE INDEX genres_albums_album_id ON genres_albums(album_id); 3519CREATE INDEX collections_albums_album_id ON collections_albums(album_id); 3520CREATE INDEX songs_playlist_id ON playlist_songs(playlist_id); 3521CREATE INDEX recommendations_album_id ON recommendations(album_id);`; 3522 3523function freshCoreDb() { const db = new MAG.SQL.Database(); db.run(CORE_SCHEMA); return db; } 3524 3525// reverse of the generator's escaping: \\ \t \n \r 3526function unesc(s) { 3527 if (s.indexOf("\\") < 0) return s; 3528 let out = ""; 3529 for (let k = 0; k < s.length; k++) { 3530 const c = s[k]; 3531 if (c === "\\" && k + 1 < s.length) { 3532 const n = s[++k]; 3533 out += n === "t" ? "\t" : n === "n" ? "\n" : n === "r" ? "\r" : n; 3534 } else out += c; 3535 } 3536 return out; 3537} 3538const splitU = (line) => line.split("\t").map(unesc); // fields never contain a real TAB (escaped) 3539const numOrNull = (s) => (s === "" ? null : +s); 3540// rebuild songs.mp3 exactly as the catalog generator stored it 3541function reconMp3(trackNo, name, stem) { 3542 return String(trackNo === "" ? 0 : trackNo).padStart(2, "0") + "-" + 3543 String(name).replace(/:/g, "_") + "-" + stem + ".mp3"; 3544} 3545 3546// Walk "@table\t<count>\n<rowsâ¦>" sections, calling fn(name, rows[]) for each. 3547function eachSection(text, magic, fn) { 3548 const lines = text.split("\n"); 3549 if (lines[0] !== magic) throw new Error("bad catalog header (expected " + magic + ")"); 3550 let i = 1; 3551 while (i < lines.length) { 3552 if (!lines[i]) { i++; continue; } 3553 if (lines[i][0] !== "@") throw new Error("expected @section at line " + (i + 1)); 3554 const tab = lines[i].indexOf("\t"); 3555 const name = lines[i].slice(1, tab); 3556 const count = +lines[i].slice(tab + 1); 3557 i++; 3558 fn(name, lines.slice(i, i + count)); 3559 i += count; 3560 } 3561} 3562 3563// wave 1: populate an empty (CORE_SCHEMA) db, then build indexes. 3564function importCatalog(db, text) { 3565 const stemOf = Object.create(null); // album_id -> filename stem (wire-only, for mp3) 3566 db.run("BEGIN"); 3567 const insert = (sql, build) => (rows) => { 3568 const st = db.prepare(sql); 3569 for (const r of rows) st.run(build(splitU(r))); 3570 st.free(); 3571 }; 3572 const handlers = { 3573 artists: insert("INSERT INTO artists(artists_id,name,description,page,city,state,country) VALUES (?,?,?,?,?,?,?)", 3574 f => [+f[0], f[1], f[2], f[3] ?? "", f[4] ?? "", f[5] ?? "", f[6] ?? ""]), // f[3]=page, f[4..6]=city/state/country; all absent in older catalogs â "" 3575 albums: (rows) => { 3576 const st = db.prepare("INSERT INTO albums(album_id,artist_id,name,sku,release_date,popularity) VALUES (?,?,?,?,?,?)"); 3577 for (const r of rows) { 3578 const f = splitU(r); // id, artist_id, name, sku, stem, release_date, popularity 3579 stemOf[f[0]] = f[4]; 3580 st.run([+f[0], +f[1], f[2], f[3], numOrNull(f[5]), numOrNull(f[6])]); 3581 } 3582 st.free(); 3583 }, 3584 songs: (rows) => { 3585 const st = db.prepare("INSERT INTO songs(song_id,album_id,name,track_no,duration,mp3) VALUES (?,?,?,?,?,?)"); 3586 for (const r of rows) { 3587 const f = splitU(r); // id, album_id, name, track_no, duration, mp3_override 3588 const mp3 = f[5] !== "" ? f[5] : reconMp3(f[3], f[2], stemOf[f[1]] || ""); 3589 st.run([+f[0], +f[1], f[2], numOrNull(f[3]), numOrNull(f[4]), mp3]); 3590 } 3591 st.free(); 3592 }, 3593 genres: insert("INSERT INTO genres VALUES (?,?)", f => [+f[0], f[1]]), 3594 collections: insert("INSERT INTO collections VALUES (?,?)", f => [+f[0], f[1]]), 3595 genres_albums: insert("INSERT INTO genres_albums VALUES (?,?)", f => [+f[0], +f[1]]), 3596 collections_albums: insert("INSERT INTO collections_albums VALUES (?,?)", f => [+f[0], +f[1]]), 3597 playlists: insert("INSERT INTO playlists VALUES (?,?,?,?)", f => [+f[0], numOrNull(f[1]), numOrNull(f[2]), f[3]]), 3598 playlist_songs: insert("INSERT INTO playlist_songs VALUES (?,?,?)", f => [+f[0], +f[1], +f[2]]), 3599 recommendations: (rows) => { // grouped: seed \t rec rec rec⦠(rank = position) 3600 const st = db.prepare("INSERT INTO recommendations VALUES (?,?,?)"); 3601 for (const r of rows) { 3602 const tab = r.indexOf("\t"); 3603 const seed = +r.slice(0, tab); 3604 const ids = r.slice(tab + 1).split(" "); 3605 for (let k = 0; k < ids.length;
3605 k++) if (ids[k]) st.run([seed, +ids[k], k + 1]); 3606 } 3607 st.free(); 3608 }, 3609 }; 3610 eachSection(text, "MTCORE1", (name, rows) => { if (handlers[name]) handlers[name](rows); }); 3611 db.run("COMMIT"); 3612 db.run(CORE_INDEXES); 3613} 3614 3615// wave 2: UPDATE descriptions/bios into the already-loaded rows. 3616function importDescriptions(db, text) { 3617 db.run("BEGIN"); 3618 eachSection(text, "MTDESC1", (name, rows) => { 3619 const sql = name === "album_descriptions" ? "UPDATE albums SET description=? WHERE album_id=?" 3620 : name === "artist_bios" ? "UPDATE artists SET bio=? WHERE artists_id=?" 3621 : null; 3622 if (!sql) return; 3623 const st = db.prepare(sql); 3624 for (const r of rows) { const f = splitU(r); st.run([f[1], +f[0]]); } 3625 st.free(); 3626 }); 3627 db.run("COMMIT"); 3628} 3629 3630// fetch a gzipped text file -> decoded string. withProgress shows the loader bar. 3631// --- resilient startup fetch ------------------------------------------------- 3632// The big startup transfers (the sql.js wasm blob ~653KB and the catalog .gz 3633// ~585KB) are large same-origin fetches. Over HTTP/3 a stream can occasionally 3634// reset mid-body, so fetch()/the stream reader rejects with "Load failed" and the 3635// app fails to start. Retry a few times with a short pause: after an h3 failure 3636// the browser demotes the endpoint to HTTP/2 for the retry, so the next attempt 3637// goes through â the same reason a manual reload fixes it, but automatic. 3638function pause(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } 3639async function fetchRetry(fn, tries = 3, delayMs = 500) { 3640 for (let attempt = 1; ; attempt++) { 3641 try { return await fn(); } 3642 catch (e) { 3643 if (attempt >= tries) throw e; 3644 console.warn(`startup fetch failed (attempt ${attempt}/${tries}): ${e.message} â retrying in ${delayMs}ms`); 3645 await pause(delayMs); 3646 } 3647 } 3648} 3649 3650async function fetchCatalogText(url, withProgress) { 3651 const gz = withProgress ? await downloadCatalog(url, 0) 3652 : await fetch(url).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); return r.arrayBuffer(); }); 3653 const buf = await gunzip(gz); 3654 return new TextDecoder("utf-8").decode(new Uint8Array(buf)); 3655} 3656 3657// wave 2 â background; refresh the current view so notes/bios appear when ready. 3658async function loadDescriptions() { 3659 if (MAG.descLoaded) return; 3660 try { 3661 const text = await fetchRetry(() => fetchCatalogText(DESC_URL + "?v=" + MAG.cfg.catalog_version, false)); 3662 importDescriptions(MAG.db, text); 3663 MAG.descLoaded = true; 3664 if (!MAG.loading) route(); 3665 } catch (e) { /* offline / transient â retry on next load */ } 3666} 3667 3668/* Hardcoded config â there is no server. The catalog version is the same-origin 3669 * catalog.crc, used both as the cache-buster (?v=) and to detect updates. If it's 3670 * unreachable we still boot (version 0). */ 3671async function loadConfig() { 3672 let version = 0; 3673 try { 3674 const t = (await fetch(CRC_URL).then((r) => r.text())).trim(); 3675 if (t) version = t; 3676 } catch (e) { /* boot anyway */ } 3677 return { catalog_url: CATALOG_URL, catalog_version: version, catalog_bytes: 0, 3678 image_base: "", stream_base: "" }; 3679} 3680 3681/* Background catalog refresh â at most once per 24h on load: re-reads the 3682 * same-origin catalog.crc; if the CRC moved (a fresh catalog was deployed), 3683 * REIMPORTS FROM SCRATCH into a brand-new sql.js Database (a fresh db is a clean 3684 * slate â no stale rows, nothing to DROP), loads BOTH waves into it, then 3685 * hot-swaps it in (no page reload). Building fully before swapping means the 3686 * live view never briefly loses its descriptions. */ 3687async function autoUpdateCatalog() { 3688 const KEY = "magnatune.catalogCheck"; 3689 if (Date.now() - (+localStorage.getItem(KEY) || 0) < 24 * 3600 * 1000) return; 3690 try { 3691 const remote = (await fetch(CRC_URL).then((r) => r.text())).trim(); 3692 localStorage.setItem(KEY, Date.now()); 3693 if (!remote || remote == MAG.cfg.catalog_version) return; 3694 const fresh = freshCoreDb(); 3695 importCatalog(fresh, await fetchCatalogText(CATALOG_URL + "?v=" + remote, false)); 3696 let gotDesc = false; 3697 try { importDescriptions(fresh, await fetchCatalogText(DESC_URL + "?v=" + remote, false)); gotDesc = true; } 3698 catch (e) { /* descriptions are optional; load lazily after swap */ } 3699 const old = MAG.db; 3700 MAG.db = fresh; 3701 MAG.cfg.catalog_version = remote; 3702 MAG.descLoaded = gotDesc; 3703 try { old.close(); } catch (e) {} 3704 buildSlugMaps(); // rebuild slug->id maps against the refreshed catalog 3705 dedupeFavorites(); 3706 Suppress.rebuild(); 3707 route(); // re-render the current view against the new catalog 3708 if (!gotDesc) loadDescriptions(); 3709 } catch (e) { /* offline / transient â try again next load */ } 3710} 3711 3712/* ===================== shell + router ===================== */ 3713// Two tab groups mirroring the native Catalyst app's iPhone-mode toolbars: 3714// primary â top tab bar, secondary â bottom tab bar (see the responsive CSS).
3715// In the wide layout both stack vertically at the top of the left sidebar 3716// (secondary directly under primary; the spacer+mascot fill the bottom). 3717const NAV_ITEMS = { 3718 popular: { label: "Popular", icon: "fa-fire" }, 3719 artist: { label: "Artists", icon: "fa-users" }, 3720 album: { label: "Albums", icon: "fa-record-vinyl" }, 3721 songs: { label: "Songs", icon: "fa-music" }, 3722 genre: { label: "Genres", icon: "fa-guitar" }, 3723 tag: { label: "Tags", icon: "fa-tag" }, 3724 featured: { label: "Featured", icon: "fa-star" }, 3725 search: { label: "Search", icon: "fa-magnifying-glass" }, 3726 favorites: { label: "Favorites", icon: "fa-heart" }, 3727 playlists: { label: "Playlists", icon: "fa-list" }, 3728 settings: { label: "Settings", icon: "fa-gear" }, 3729}; 3730// The native app groups the nav DIFFERENTLY per layout, and so do we: 3731// ⢠Wide sidebar (iOS desktop): Tags + Featured live in the top Browse group; Search + 3732// library sit in the lower group; Settings is pinned at the very bottom, under the mascot. 3733// ⢠Narrow tab bars (iOS iPhone): Tags + Featured move down to the bottom (secondary) bar, 3734// and Settings rides in that bottom bar too (no mascot on narrow). 3735// renderNav() picks the right grouping for the current breakpoint and rebuilds on resize. 3736const NAV_WIDE = { 3737 primary: ["popular", "artist", "album", "genre", "tag", "featured", "songs"], // Browse 3738 secondary: ["favorites", "playlists"], // Library 3739 tertiary: ["search"], // Search (own group, like iOS) 3740 bottom: ["settings"], // pinned below the mascot 3741}; 3742const NAV_NARROW = { 3743 primary: ["popular", "artist", "album", "songs", "genre", "search"], 3744 secondary: ["favorites", "playlists", "tag", "featured", "settings"], 3745}; 3746const NAV_SECS = Object.keys(NAV_ITEMS); 3747const navItem = (sec) => { const n = NAV_ITEMS[sec]; return `<a id="nav-${sec}" href="#/${sec}" class="nav-item"><i class="fa-solid ${n.icon}"></i><span>${n.label}</span></a>`; }; 3748const _navMQ = window.matchMedia("(max-width:1000px)"); 3749function renderNav() { 3750 const g = _navMQ.matches ? NAV_NARROW : NAV_WIDE; 3751 const fill = (cls, secs) => { const box = document.querySelector(cls); if (box) box.innerHTML = (secs || []).map(navItem).join(""); }; 3752 fill(".nav-primary", g.primary); 3753 fill(".nav-secondary", g.secondary); 3754 fill(".nav-tertiary", g.tertiary); // wide: Search in its own group; narrow: empty (Search is in the top bar) 3755 fill(".nav-bottom", g.bottom); // wide: Settings under the mascot; narrow: empty (Settings is in the bottom bar) 3756 if (MAG.lastSec) setActive(MAG.lastSec); // re-apply active highlight after a layout rebuild 3757} 3758_navMQ.addEventListener("change", renderNav); 3759 3760function buildShell() { 3761 el("app").innerHTML = 3762 `<div class="appframe"> 3763 <div class="shell"> 3764 <nav class="sidebar"> 3765 <a href="#/popular" class="brand-link" title="Popular"><img class="brand-logo" src="magnatune_logo.png?v=1" alt="Magnatune"></a> 3766 <div class="nav nav-primary"></div> 3767 <div class="nav nav-secondary"></div> 3768 <div class="nav nav-tertiary"></div> 3769 <div class="nav-spacer"></div> 3770 <a class="mascot-link" href="#/help" title="App help"><img class="mascot" loading="lazy" src="magnatune_mascot.png?v=1" alt="Help"></a> 3771 <div class="nav nav-bottom"></div> 3772 </nav> 3773 <div class="maincol"> 3774 <main class="content" id="content"></main> 3775 <div class="miniplayer" id="miniplayer" hidden> 3776 <div class="mp-meta" id="mp-meta" title="Now playing"> 3777 <div class="mp-cover" id="mp-cover"></div> 3778 <div class="mp-text"><div class="mp-title" id="mp-title"></div><div class="mp-artist" id="mp-artist"></div></div> 3779 </div> 3780 <div class="mp-center"> 3781 <div class="mp-buttons"> 3782 <button id="mp-repeat" title="Repeat"><i class="fa-solid fa-repeat"></i></button> 3783 <button id="mp-shuffle" title="Shuffle"><i class="fa-solid fa-shuffle"></i></button> 3784 <button id="mp-prev" title="Previous"><i class="fa-solid fa-backward-step"></i></button> 3785 <button id="mp-playpause" class="primary" title="Play / Pause"><i class="fa-solid fa-play"></i></button> 3786 <button id="mp-next" title="Next"><i class="fa-solid fa-forward-step"></i></button> 3787 <button id="mp-dislike" title="Dislike & skip"><i class="fa-solid fa-heart-crack"></i></button> 3788 </div> 3789 <div class="mp-seekrow">
3790 <span class="mp-time" id="mp-cur">0:00</span> 3791 <input type="range" id="mp-seek" min="0" max="1000" value="0"> 3792 <span class="mp-time" id="mp-dur">0:00</span> 3793 </div> 3794 </div> 3795 <div class="mp-right" id="mp-right"></div> 3796 </div> 3797 </div> 3798 </div> 3799 </div>`; 3800 renderNav(); 3801 Player.init(); 3802 wirePlayer(); 3803} 3804function setActive(sec) { 3805 MAG.lastSec = sec; // remembered so renderNav() can re-apply the highlight after a layout rebuild 3806 // user playlists share the Playlists nav entry; everything else maps 1:1 3807 const nav = sec === "playlist" ? "playlists" : sec; 3808 NAV_SECS.forEach((s) => { 3809 const a = el("nav-" + s); 3810 if (a) a.classList.toggle("active", s === nav); 3811 }); 3812} 3813 3814function route() { 3815 const parts = currentToken().split("/").filter(Boolean); 3816 const sec = parts[0] || "popular"; 3817 const c = el("content"); 3818 if (!c) return; 3819 if (MAG.loading) { setActive(sec); return; } // catalog not ready â keep the loader visible 3820 MAG.lists = {}; // drop previous page's registered lists (the playing queue is held by Player) 3821 c.scrollTop = 0; 3822 setActive(sec); 3823 // resolve a name-slug detail segment to a catalog id (undefined = no segment = overview; 3824 // null = a segment was given but matched nothing â show "not found") 3825 const lookup = (map, key) => key == null ? undefined : (map.has(key) ? map.get(key) : null); 3826 const aSlug = parts[1] != null ? slug(decodeURIComponent(parts[1])) : undefined; 3827 const bSlug = parts[2] != null ? slug(decodeURIComponent(parts[2])) : undefined; 3828 const notFound = (what) => { c.innerHTML = `<div class="pad">${what} not found.</div>`; }; 3829 try { 3830 switch (sec) { 3831 case "popular": renderPopular(c); break; 3832 case "artist": { 3833 if (aSlug === undefined) { renderArtists(c); break; } 3834 const id = lookup(Slug.artist, aSlug); 3835 id ? renderArtist(c, id) : notFound("Artist"); break; 3836 } 3837 case "album": { 3838 if (aSlug === undefined) { renderAlbums(c); break; } 3839 const id = lookup(Slug.album, bSlug === undefined ? null : aSlug + "/" + bSlug); 3840 const songSlug = parts[3] != null ? slug(decodeURIComponent(parts[3])) : undefined; 3841 id ? renderAlbum(c, id, songSlug) : notFound("Album"); break; 3842 } 3843 case "genre": { 3844 if (aSlug === undefined) { renderGenres(c); break; } 3845 const id = lookup(Slug.genre, aSlug); 3846 id ? renderGenre(c, id) : notFound("Genre"); break; 3847 } 3848 case "tag": { 3849 if (aSlug === undefined) { renderTags(c); break; } 3850 const id = lookup(Slug.tag, aSlug); 3851 id ? renderTag(c, id) : notFound("Tag"); break; 3852 } 3853 case "featured": { 3854 if (aSlug === undefined) { renderFeatured(c); break; } 3855 const id = lookup(Slug.featured, aSlug); 3856 id ? renderFeaturedPlaylist(c, id) : notFound("Playlist"); break; 3857 } 3858 case "search": renderSearch(c, parts.slice(1).map(decodeURIComponent).join("/")); break; 3859 case "songs": renderSongs(c, parts.slice(1).map(decodeURIComponent).join("/")); break; 3860 case "favorites": renderFavorites(c); break; 3861 case "playlists": renderPlaylists(c); break; 3862 case "recent": renderRecentlyPlayed(c); break; // auto "Recently Played" pseudo-playlist 3863 case "recommended": renderRecommended(c); break; // per-member recommendations (todo 10355065694) 3864 case "playlist": renderUserPlaylist(c, +parts[1]); break; // user playlists keep numeric ids (local, no stable name) 3865 case "settings": renderSettings(c); break; 3866 case "downloads": renderDownloads(c); break; 3867 case "help": renderHelp(c); break; 3868 case "ios": renderIosInstall(c); break; 3869 case "info": renderInfo(c, parts[1]); break; 3870 default: c.innerHTML = `<div class="pad">Not found.</div>`; 3871 } 3872 highlightPlayingRow(); 3873 wireDescToggles(); 3874 LazyImg.scan(c); // throttled cover loading for the just-rendered grid 3875 } catch (e) { 3876 console.error(e); 3877 c.innerHTML = `<div class="error"><div>Render error</div><div>${esc(e.message)}</div></div>`; 3878 } 3879} 3880 3881/* ===================== boot ===================== */ 3882/* ---- first-load progress: the catalog SQLite is a few MB, so show what's happening ---- */ 3883// Render the loader into the content area once the shell exists (so the whole app â 3884// sidebar, logo, nav â is visible while the catalog downloads); before that, into #app. 3885function showLoader(msg, withBar) { 3886 const host = el("content") || el("app"); 3887 if (!host) return; 3888 if (!host.querySelector("#loader-card")) { 3889 host.innerHTML = 3890 `<div class="loader" id="loader-card"> 3891 <div class="loader-spinner"></div> 3892 <div class="loader-msg" id="loader-msg"></div> 3893 <div class="loader-bar" id="loader-bar" hidden><div class="loader-fill" id="loader-fill"></div></div> 3894 <div class="loader-sub" id="loader-sub"></div> 3895 </div>`; 3896 } 3897 if (msg != null) el("loader-msg").textContent = msg; 3898 if (withBar) el("loader-bar").hidden = false;
3899} 3900const fmtMB = (b) => (b / 1048576).toFixed(1) + " MB"; 3901const fmtETA = (s) => 3902 !isFinite(s) || s <= 0 ? "almost done" : 3903 s < 60 ? "~" + Math.ceil(s) + "s left" : 3904 "~" + Math.floor(s / 60) + "m " + Math.ceil(s % 60) + "s left"; 3905function updateLoader(received, total, start) { 3906 const pct = Math.min(100, Math.round((received / total) * 100)); 3907 const elapsed = (performance.now() - start) / 1000; 3908 const rate = elapsed > 0 ? received / elapsed : 0; 3909 const eta = rate > 0 ? Math.max(0, total - received) / rate : Infinity; 3910 const fill = el("loader-fill"); if (fill) fill.style.width = pct + "%"; 3911 const msg = el("loader-msg"); if (msg) msg.textContent = `Downloading music catalog⦠${pct}%`; 3912 const sub = el("loader-sub"); 3913 if (sub) sub.textContent = 3914 `${fmtMB(received)} of ${fmtMB(total)} · ${(rate / 1048576).toFixed(1)} MB/s · ${received >= total ? "finishingâ¦" : fmtETA(eta)}`; 3915} 3916// Stream the catalog download so we can show a real % + ETA (browser auto-decompresses 3917// gzip, so received bytes match catalog_bytes). Falls back to a plain fetch if streaming 3918// or the size is unavailable. 3919// Lazy-load the vendored fflate gunzip fallback. Only reached on browsers without 3920// DecompressionStream (Safari < 16.4 / iOS < 16.4), so modern browsers never fetch 3921// it. Memoized so concurrent catalog waves share one load. 3922let _fflateLoad = null; 3923function loadFflate() { 3924 if (window.fflate) return Promise.resolve(window.fflate); 3925 if (_fflateLoad) return _fflateLoad; 3926 _fflateLoad = new Promise((resolve, reject) => { 3927 const s = document.createElement("script"); 3928 s.src = "fflate.min.js"; 3929 s.onload = () => window.fflate ? resolve(window.fflate) 3930 : reject(new Error("fflate loaded but global missing")); 3931 s.onerror = () => reject(new Error("failed to load fflate.min.js")); 3932 document.head.appendChild(s); 3933 }); 3934 return _fflateLoad; 3935} 3936 3937// Inflate a gzip ArrayBuffer in the browser. Prefer the native DecompressionStream; 3938// fall back to the vendored fflate on browsers that lack it (Safari < 16.4 / iOS 3939// < 16.4 threw "Can't find variable: DecompressionStream" and never started). 3940// Guard: if the bytes lack the gzip magic (e.g. a server applied Content-Encoding 3941// and already inflated the stream), return them untouched so we never double-decompress. 3942async function gunzip(buf) { 3943 const u8 = new Uint8Array(buf); 3944 if (u8.length < 2 || u8[0] !== 0x1f || u8[1] !== 0x8b) return buf; 3945 if (typeof DecompressionStream !== "undefined") { 3946 const stream = new Blob([buf]).stream().pipeThrough(new DecompressionStream("gzip")); 3947 return await new Response(stream).arrayBuffer(); 3948 } 3949 const fflate = await loadFflate(); 3950 return fflate.gunzipSync(u8); // Uint8Array â the caller wraps it in new Uint8Array() 3951} 3952 3953async function downloadCatalog(url, total) { 3954 const resp = await fetch(url); 3955 if (!resp.ok) throw new Error("catalog fetch HTTP " + resp.status); 3956 if (!total) { const cl = +resp.headers.get("content-length"); if (cl > 0) total = cl; } 3957 if (!resp.body || !resp.body.getReader || !total) { 3958 showLoader("Downloading music catalogâ¦"); 3959 return await resp.arrayBuffer(); 3960 } 3961 showLoader("Downloading music catalogâ¦", true); 3962 const reader = resp.body.getReader(); 3963 const chunks = []; let received = 0; const start = performance.now(); let lastUI = 0; 3964 for (;;) { 3965 const { done, value } = await reader.read(); 3966 if (done) break; 3967 chunks.push(value); received += value.length; 3968 const now = performance.now(); 3969 if (now - lastUI > 120) { lastUI = now; updateLoader(received, total, start); } 3970 } 3971 updateLoader(received, total, start); 3972 const out = new Uint8Array(received); let off = 0; 3973 for (const c of chunks) { out.set(c, off); off += c.length; } 3974 return out.buffer; 3975} 3976 3977// Reflect the saved theme onto <html data-theme> so the CSS variable overrides apply. 3978// "system" defers to the OS via @media (prefers-color-scheme). A tiny inline script in 3979// the shell <head> sets this first to avoid a light flash before the app boots. 3980function applyTheme() { try { document.documentElement.dataset.theme = UserData.theme || "system"; } catch (e) {} } 3981 3982// The signed, HttpOnly m3member cookie is the real session (JS can't read it); 3983// navim4 sets a readable "m3user" hint cookie beside it carrying the logged-in 3984// username. Reconcile our cached localStorage member state with that cookie on 3985// every boot, so the app reflects the ACTUAL session â a fresh device that only
3986// has the cookie, or an admin "log in as", both show logged-in â and a cleared/ 3987// expired cookie drops a stale cached login. Cookie = source of truth for 3988// session presence; localStorage stays a cache (+ holds prefs like dl formats). 3989function memberHintCookie() { 3990 const m = document.cookie.match(/(?:^|;\s*)m3user=([^;]*)/); 3991 if (!m || !m[1]) return ""; 3992 let v = m[1]; 3993 try { v = decodeURIComponent(v); } catch (e) {} 3994 return v.replace(/^"|"$/g, ""); 3995} 3996function syncMemberFromCookie() { 3997 const u = memberHintCookie(); 3998 const cur = UserData.member || { loggedIn: false, user: "" }; 3999 if (u) { 4000 if (!cur.loggedIn || cur.user !== u) UserData.setMember({ loggedIn: true, user: u }); 4001 } else if (cur.loggedIn) { 4002 UserData.setMember({ loggedIn: false, user: "" }); 4003 } 4004} 4005 4006async function boot() { 4007 // Tell the cold-load watchdog (inline in the page <head>) that the SPA script 4008 // actually loaded and is executing â reaching here means the HTTP/3 asset stall 4009 // that leaves visitors on the unstyled #seo-prerender did not happen, so the 4010 // watchdog must not fire its one-shot reload. 4011 window.__magBooted = true; 4012 try { sessionStorage.removeItem("magnatune.bootReloaded"); } catch (e) {} 4013 try { 4014 MAG.loading = true; 4015 showLoader("Connectingâ¦"); 4016 MAG.cfg = await loadConfig(); 4017 UserData.init(); // localStorage only â no catalog needed 4018 Sync.init(); // cloud-sync metadata (rev/hash/base) from localStorage 4019 syncMemberFromCookie(); // reconcile cached login with the session cookie 4020 // Best-effort flush of pending setting changes when leaving/backgrounding the page. 4021 addEventListener("pagehide", () => Sync.flushBeacon()); 4022 document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") Sync.flushBeacon(); }); 4023 fetchPlayToken(); // fire-and-forget: play logging only, never blocks the UI 4024 applyTheme(); 4025 buildShell(); // show the full UI immediately (sidebar, logo, nav, player) 4026 setupKeyboard(); // Space = play/pause (global shortcut) 4027 if (PATH_MODE) { 4028 // Links are still built as "#/â¦". Intercept their clicks â pushState the real path. 4029 document.addEventListener("click", (e) => { 4030 if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; 4031 // Controls inside a link (fav/dislike/add buttons, inputs) handle their own click â 4032 // don't let this capture-phase interceptor navigate the surrounding <a> first. 4033 if (e.target.closest("button, input, select, textarea, label")) return; 4034 const a = e.target.closest && e.target.closest('a[href^="#/"]'); 4035 if (!a) return; 4036 e.preventDefault(); 4037 go(a.getAttribute("href")); // go() strips the leading "#/" 4038 }, true); 4039 window.addEventListener("popstate", route); 4040 } else { 4041 window.addEventListener("hashchange", route); 4042 } 4043 showLoader("Loading database engineâ¦"); // progress now lives in the content area 4044 // Prefetch the wasm and hand it to sql.js as `wasmBinary` so it skips 4045 // WebAssembly.instantiateStreaming (which the browser refuses unless the .wasm 4046 // is served as application/wasm â most static hosts don't). Avoids the noisy 4047 // "wasm streaming compile failed ⦠falling back" console error; no server config. 4048 const wasmBinary = await fetchRetry(() => fetch("sql-wasm.wasm").then((r) => r.arrayBuffer())); 4049 MAG.SQL = await initSqlJs({ wasmBinary, locateFile: (f) => "" + f }); 4050 // 1) empty db with the full schema 2) download catalog text (progress bar) 4051 // 3) import it 4) app becomes usable. Descriptions follow async (5-6). 4052 const db = freshCoreDb(); 4053 const text = await fetchRetry(() => fetchCatalogText(CATALOG_URL + "?v=" + MAG.cfg.catalog_version, true)); 4054 showLoader("Preparing catalogâ¦"); 4055 importCatalog(db, text); 4056 MAG.db = db; 4057 buildSlugMaps(); // reverse name-slug -> id maps for pretty URL routing 4058 dedupeFavorites(); // clean any redundant favorites saved previously (needs the catalog) 4059 Suppress.rebuild(); // expand disliked ids through the catalog for UI suppression 4060 MAG.loading = false;
4061 route(); // render the real content over the loader 4062 if (isMember()) Sync.pull(); // pull+merge this member's cloud settings (todo 10355013029) 4063 loadDescriptions(); // wave 2 â async album notes + artist bios 4064 autoUpdateCatalog(); // background, throttled to 24h 4065 } catch (e) { 4066 console.error(e); 4067 const host = el("content") || el("app"); 4068 if (host) host.innerHTML = `<div class="error"><div>â ï¸ Failed to start Magnatune</div><div>${esc(e.message)}</div></div>`; 4069 } 4070} 4071boot();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.