Files
site/webamp.html
T

100 lines
9.4 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>