2026-10-21 06:21:50 +00:00
<!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 : 13 px 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 : 420 px ; padding : 0 16 px }
# note a { color : #ededf0 }
# bar { position : fixed ; left : 0 ; right : 0 ; bottom : 0 ; display : flex ; gap : 14 px ; justify-content : center ; padding : 10 px ; font-size : 11 px ; letter-spacing : .18 em ; color : #8a8a96 }
# bar a { color : #8a8a96 ; text-decoration : none }# bar a : hover { color : #ededf0 }
# app { position : fixed ; inset : 0 }
2026-10-21 06:47:39 +00:00
# pick { position : fixed ; left : 50 % ; top : 50 % ; transform : translate ( -50 % , -50 % ); background : #121216 ; border : 1 px solid #232329 ; border-radius : 14 px ; padding : 14 px ; min-width : 260 px ; max-width : min ( 420 px , calc ( 100 % - 32 px )); z-index : 100000 }
# pick h4 { margin : 0 0 10 px ; font-size : 10 px ; letter-spacing : .2 em ; color : #8a8a96 }
# pick button { display : block ; width : 100 % ; text-align : left ; padding : 9 px 10 px ; border-radius : 8 px ; border : 1 px solid transparent ; background : none ; color : #ededf0 ; font : 13 px ui-monospace , Menlo , monospace ; cursor : pointer }
# pick button : hover { background : #1a1a20 ; border-color : #232329 }
# pick input { width : 100 % ; box-sizing : border-box ; margin : 6 px 0 ; background : #0a0a0d ; border : 1 px solid #232329 ; border-radius : 8 px ; padding : 8 px 10 px ; color : #ededf0 ; font : 13 px ui-monospace , Menlo , monospace }
. hidden { display : none }
2026-10-21 06:21:50 +00:00
</ style >
</ head >
< body >
< div id = "app" ></ div >
< div id = "note" > loading…</ div >
2026-10-21 06:47:39 +00:00
< div id = "pick" class = "hidden" ></ div >
2026-10-21 06:21:50 +00:00
< div id = "bar" >< a href = "/" > ◂ G*NDORA</ a >< span id = "count" ></ span ></ div >
< script type = "module" >
2026-10-21 06:47:39 +00:00
/* 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. */
2026-10-21 06:21:50 +00:00
import Webamp from "https://unpkg.com/webamp@2.3.1/built/webamp.butterchurn-bundle.min.mjs" ;
2026-10-21 06:47:39 +00:00
const note = document . getElementById ( "note" ), pick = document . getElementById ( "pick" );
2026-10-21 06:21:50 +00:00
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 ); }); }
2026-10-21 06:47:39 +00:00
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` ;
2026-10-21 06:21:50 +00:00
try {
2026-10-21 06:47:39 +00:00
const [ tracks , stations , tapes ] = await Promise . all ([ all ( "tracks" ), all ( "stations" ), all ( "tapes" )]);
2026-10-21 06:21:50 +00:00
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 ));
2026-10-21 06:47:39 +00:00
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 ()));
};
2026-10-21 06:21:50 +00:00
const webamp = new Webamp ({
2026-10-21 06:47:39 +00:00
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 ,
2026-10-21 06:21:50 +00:00
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 ,
2026-10-21 06:47:39 +00:00
enableMediaSession : true ,
2026-10-21 06:21:50 +00:00
});
await webamp . renderWhenReady ( document . getElementById ( "app" ));
webamp . onClose (() => { location . href = "/" ; });
} catch ( e ) { note . textContent = "winamp could not start: " + e . message ; }
</ script >
</ body >
</ html >