Files
site/webamp.html
T

99 lines
9.4 KiB
HTML
Raw Normal View History

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>g*ndora · winamp</title>
<link rel="icon" href="favicon.svg">
<style>
html,body{margin:0;height:100%;background:#0b0b0e;color:#ededf0;font:13px ui-monospace,Menlo,Consolas,monospace}
#note{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);text-align:center;color:#8a8a96;line-height:1.7;max-width:420px;padding:0 16px}
#note a{color:#ededf0}
#bar{position:fixed;left:0;right:0;bottom:0;display:flex;gap:14px;justify-content:center;padding:10px;font-size:11px;letter-spacing:.18em;color:#8a8a96}
#bar a{color:#8a8a96;text-decoration:none}#bar a:hover{color:#ededf0}
#app{position:fixed;inset:0}
#pick{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:#121216;border:1px solid #232329;border-radius:14px;padding:14px;min-width:260px;max-width:min(420px,calc(100% - 32px));z-index:100000}
#pick h4{margin:0 0 10px;font-size:10px;letter-spacing:.2em;color:#8a8a96}
#pick button{display:block;width:100%;text-align:left;padding:9px 10px;border-radius:8px;border:1px solid transparent;background:none;color:#ededf0;font:13px ui-monospace,Menlo,monospace;cursor:pointer}
#pick button:hover{background:#1a1a20;border-color:#232329}
#pick input{width:100%;box-sizing:border-box;margin:6px 0;background:#0a0a0d;border:1px solid #232329;border-radius:8px;padding:8px 10px;color:#ededf0;font:13px ui-monospace,Menlo,monospace}
.hidden{display:none}
</style>
</head>
<body>
<div id="app"></div>
<div id="note">loading…</div>
<div id="pick" class="hidden"></div>
<div id="bar"><a href="/">◂ G*NDORA</a><span id="count"></span></div>
<script type="module">
/* g*ndora's library in Winamp. Tracks and tapes come from this origin's IndexedDB. Milkdrop runs Butterchurn's presets
on the real audio. Tapes: the Open menu (the eject button) and LOAD LIST pick a tape; SAVE LIST makes a tape from
the playlist. Skins: a set from the Winamp Skin Museum (skins.webamp.org) under Options › Skins, or paste a museum
link. */
import Webamp from "https://unpkg.com/webamp@2.3.1/built/webamp.butterchurn-bundle.min.mjs";
const note = document.getElementById("note"), pick = document.getElementById("pick");
function open() { return new Promise((ok, no) => { const r = indexedDB.open("gandora", 3); r.onupgradeneeded = () => { const d = r.result; for (const s of ["tracks", "stations", "tapes"]) if (!d.objectStoreNames.contains(s)) d.createObjectStore(s, { keyPath: "id" }); }; r.onsuccess = () => ok(r.result); r.onerror = () => no(r.error); }); }
async function all(store) { const d = await open(); return new Promise((ok, no) => { const r = d.transaction(store).objectStore(store).getAll(); r.onsuccess = () => ok(r.result); r.onerror = () => no(r.error); }); }
async function put(row, store) { const d = await open(); return new Promise((ok, no) => { const tx = d.transaction(store, "readwrite"); tx.objectStore(store).put(row); tx.oncomplete = ok; tx.onerror = () => no(tx.error); }); }
/* a small chooser in place of prompt(): a title, a list of buttons, maybe a text box */
function choose(title, items, { input = "", go = "OK" } = {}) {
return new Promise((ok) => {
pick.innerHTML = ""; pick.append(Object.assign(document.createElement("h4"), { textContent: title }));
const done = (v) => { pick.classList.add("hidden"); ok(v); };
for (const [label, value] of items) { const b = document.createElement("button"); b.textContent = label; b.addEventListener("click", () => done(value)); pick.append(b); }
if (input !== null && input !== false) {
const inp = document.createElement("input"); inp.placeholder = input; pick.append(inp);
const b = document.createElement("button"); b.textContent = go; b.addEventListener("click", () => done(inp.value.trim() ? { text: inp.value.trim() } : null)); pick.append(b);
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") b.click(); });
}
const c = document.createElement("button"); c.textContent = "cancel"; c.style.color = "#8a8a96"; c.addEventListener("click", () => done(null)); pick.append(c);
pick.classList.remove("hidden");
});
}
/* the Winamp Skin Museum: a set from its front page (md5 → the museum's own CDN, which allows cross-origin loads) */
const MUSEUM = [["5e4f10275dcb1fb211d4a8b4f1bda236", "base-2.91"], ["cd251187a5e6ff54ce938d26f1f2de02", "Winamp3 Classified"], ["b0fb83cc20af3abe264291bb17fb2a13", "Winamp5 Classified"], ["d6010aa35bed659bc1311820daa4b341", "Bento Classified"], ["971c1afc05b56f5160903d0748b73239", "Deus Ex Amp"], ["48bbdbbeb03d347e59b1eebda4d352d0", "Zelda Amp 3"], ["03a967351bc69b83a3c063782997d434", "Microchip 2"], ["80fb8e391fc925f297174a8fd9c5b6ad", "Fallout Pip-Boy 2000"], ["5ea2d974dad5507b83a6ddff7003a8ee", "Yufo"], ["7b9df7892503371430aaf99ea7a172c0", "Unreal"], ["eda35a53d88257c88c0dbf7ca9c4a98d", "SoundLair"], ["3e5f4c237cbad07fce684f0e171abe6a", "Super Mario Land (GB)"], ["e06363340d1e7db147b2b61e9c3601a4", "ascii"], ["93220dd1556a346036d200a8df07b35f", "DOOM II Amp"], ["429f17eea62cf15aa88ea39da152c0e3", "Oblivion Amp"], ["670c7a789344732ca0c32b0eb0ad3c90", "Boca Amp"], ["f7ac1decc3785bc34c07a6caa4661ace", "Rei Ayanami"], ["cd90a3a4fd08720d50b16d0a2bcb1ad4", "weezer2"], ["139c3e8f020b24c2bc3562a9ecacb231", "Yuna FFX"], ["cb7102ec7a4f6cefbc63d0dc0aecea16", "Purple Glow"], ["bb7fd9baf2292c81436825248837acc7", "Blue Jeans"], ["3b3b8b07fb7d268f6092d4321f0a9b9c", "Necromech"], ["d632abee67ceb6878aa5ba3770bb7894", "Tenchi Muyo Aeka"], ["546dc4154dfa3e1ec605acf5de18a888", "Bag End's Gardener"], ["f240348986b9e19578b033d639b263e5", "Excel Saga"], ["ebcf7047f0b7dacbe827a0eef3bb4608", "VGCats"], ["ef264ad04547b893eef282aed69df5f9", "Super Mario Land"], ["4e837b2ed5050ad8d10e9ae37313fca1", "The People's Amp"], ["47597ab8e5ffcd39686d455c10c3b436", "Garfield"], ["52fb162d1dcd616659eef2fbad46b238", "Mr Bean"], ["3e0a37f5fcd0e9dedc035eeb33932545", "george"], ["14289db7b3fc18c2de6f4e93297bd6d3", "goku"], ["641297cf9994f5bae1f7a1e50342c52d", "Jackie Chan"], ["b9c6d42793c2276ef05ec2c73d6fc4e6", "Sausage Amp"], ["ffb93ba1425a5f17143fe5c8642c6045", "Morbamp"]];
const skinUrl = (md5) => `https://r2.webampskins.org/skins/${md5}.wsz`;
try {
const [tracks, stations, tapes] = await Promise.all([all("tracks"), all("stations"), all("tapes")]);
const byStation = Object.fromEntries(stations.map((s) => [s.id, s.name || s.seed]));
const rows = tracks.filter((t) => t.blob && t.spec && !t.spec.break).sort((a, b) => (b.kept - a.kept) || (b.made - a.made));
const byId = Object.fromEntries(rows.map((t) => [t.id, t]));
const toTrack = (t) => ({ blob: t.blob, metaData: { title: t.spec.title, artist: byStation[t.spec.stationId] || t.spec.seed || "g*ndora" }, duration: t.spec.seconds || 120 });
const kept = rows.filter((t) => t.kept);
const allTapes = [{ id: "kept", name: "KEPT", ids: kept.map((t) => t.id), virtual: true }, ...tapes];
document.getElementById("count").textContent = rows.length ? `${rows.length} TRACKS · ${allTapes.length} TAPES` : "";
if (!rows.length) note.innerHTML = 'nothing in this library yet. <a href="/">play a station</a> first, then come back.'; else note.remove();
const pickTape = async () => {
const v = await choose("A TAPE", allTapes.map((tp) => [`${tp.name} · ${tp.ids.length}`, tp]), { input: null });
if (!v) return null;
return v.ids.map((id) => byId[id]).filter(Boolean).map(toTrack);
};
const saveTape = async (list) => {
const v = await choose("SAVE THE PLAYLIST AS A TAPE", [], { input: "tape name", go: "SAVE" }); if (!v) return;
const ids = list.map((tr) => { const m = tr.metaData || {}; const r = rows.find((t) => t.spec.title === m.title && (byStation[t.spec.stationId] || t.spec.seed || "g*ndora") === m.artist); return r && r.id; }).filter(Boolean);
if (!ids.length) { alert("Those tracks aren't in the library."); return; }
await put({ id: "tape-" + Date.now(), name: v.text.slice(0, 60), ids, made: Date.now(), last: Date.now() }, "tapes");
alert(`Tape “${v.text}” saved: ${ids.length} tracks. It's in the library.`);
};
const pickSkin = async () => {
const v = await choose("A SKIN FROM THE MUSEUM", [], { input: "paste a skins.webamp.org link or an md5", go: "LOAD" }); if (!v) return;
const m = v.text.match(/[a-f0-9]{32}/i); if (!m) { alert("No skin id in that."); return; }
webamp.setSkinFromUrl(skinUrl(m[0].toLowerCase()));
};
const webamp = new Webamp({
initialTracks: kept.length ? kept.map(toTrack) : rows.map(toTrack),
availableSkins: MUSEUM.map(([md5, name]) => ({ url: skinUrl(md5), name })),
filePickers: [{ contextMenuName: "Mixtape…", filePicker: pickTape, requiresNetwork: false }, { contextMenuName: "Skin from the museum…", filePicker: async () => { await pickSkin(); return []; }, requiresNetwork: true }],
handleLoadListEvent: pickTape,
handleAddUrlEvent: pickTape,
handleSaveListEvent: saveTape,
windowLayout: { main: { position: { top: 0, left: 0 } }, equalizer: { position: { top: 116, left: 0 } }, playlist: { position: { top: 232, left: 0 }, size: { extraHeight: 4, extraWidth: 0 } }, milkdrop: { position: { top: 0, left: 275 }, size: { extraHeight: 12, extraWidth: 7 } } },
enableHotkeys: true,
enableMediaSession: true,
});
await webamp.renderWhenReady(document.getElementById("app"));
webamp.onClose(() => { location.href = "/"; });
} catch (e) { note.textContent = "winamp could not start: " + e.message; }
</script>
</body>
</html>