1/* yura.ai music â a stereomood-style mood radio, powered by the open Audius network. 2 No API key required: Audius exposes a public, CORS-friendly HTTP API. */ 3 4const APP = 'yura.ai-music'; 5 6/* Audius gateway + a few discovery-node fallbacks in case one is down. */ 7const HOSTS = [ 8 'https://api.audius.co', 9 'https://discoveryprovider.audius.co', 10 'https://discoveryprovider2.audius.co', 11 'https://discoveryprovider3.audius.co' 12]; 13let hostIdx = 0; 14 15/* ---------- moods ---------- 16 Each tile maps to Audius search terms + the platform's own "mood" tags, 17 which we use to sort the most fitting tracks to the front. */ 18const MOODS = [ 19 { id:'happy', label:'Happy', emoji:'ð', sub:'sunny & upbeat', 20 grad:'linear-gradient(135deg,#ff9a3c,#ff5e62)', q:['happy upbeat pop','feel good summer'], m:['Upbeat','Excited','Empowering'] }, 21 { id:'chill', label:'Chill', emoji:'ð¿', sub:'lofi & easy', 22 grad:'linear-gradient(135deg,#43c6ac,#191654)', q:['chill lofi','chillhop relax'], m:['Easygoing','Peaceful','Cool'] }, 23 { id:'focus', label:'Focus', emoji:'ð¯', sub:'deep work beats', 24 grad:'linear-gradient(135deg,#2b5876,#4e4376)', q:['focus study beats','ambient concentration'], m:['Sophisticated','Serious','Peaceful'] }, 25 { id:'energetic', label:'Energetic', emoji:'â¡', sub:'move your body', 26 grad:'linear-gradient(135deg,#f7971e,#ff2d55)', q:['workout energy edm','high energy electronic'], m:['Energizing','Rowdy','Aggressive'] }, 27 { id:'romantic', label:'Romantic', emoji:'ð', sub:'warm & tender', 28 grad:'linear-gradient(135deg,#ff6a88,#ff99ac)', q:['romantic rnb','love soul slow'], m:['Romantic','Sensual','Tender'] }, 29 { id:'melancholy',label:'Melancholy', emoji:'ð§ï¸', sub:'rainy & wistful', 30 grad:'linear-gradient(135deg,#485563,#29323c)', q:['sad melancholy piano','emotional indie'], m:['Melancholy','Yearning','Sentimental'] }, 31 { id:'party', label:'Party', emoji:'ðª©', sub:'turn it up', 32 grad:'linear-gradient(135deg,#8e2de2,#e94057)', q:['party dance house','club banger'], m:['Excited','Rowdy','Energizing'] }, 33 { id:'dreamy', label:'Dreamy', emoji:'ð', sub:'floaty & ambient', 34 grad:'linear-gradient(135deg,#5f2c82,#49a09d)', q:['dreamy ambient','ethereal synthwave'], m:['Peaceful','Cool','Sophisticated'] }, 35 { id:'sleep', label:'Sleep', emoji:'ð', sub:'drift away', 36 grad:'linear-gradient(135deg,#0f2027,#203a43)', q:['sleep ambient calm','deep relaxation drone'], m:['Peaceful','Tender'] }, 37 { id:'adventure', label:'Epic', emoji:'ð', sub:'cinematic & bold', 38 grad:'linear-gradient(135deg,#c31432,#240b36)', q:['epic cinematic orchestral','adventure trailer'], m:['Stirring','Empowering','Fiery'] }, 39 { id:'groovy', label:'Groovy', emoji:'ðº', sub:'funk & soul', 40 grad:'linear-gradient(135deg,#f12711,#f5af19)', q:['funk groove disco','soulful house'], m:['Cool','Easygoing','Upbeat'] }, 41 { id:'surprise', label:'Surprise me',emoji:'ð²', sub:'trending now', 42 grad:'linear-gradient(135deg,#36d1dc,#5b86e5)', q:[], m:[], trending:true } 43]; 44 45/* ---------- state ---------- */ 46let queue = []; 47let qi = -1; 48let currentMood = null; 49const audio = new Audio(); 50audio.preload = 'auto'; 51audio.crossOrigin = 'anonymous'; 52 53/* ---------- dom ---------- */ 54const $ = (s) => document.querySelector(s); 55const moodGrid = $('#moods'); 56const player = $('#player'); 57const elArt = $('#art'); 58const elTitle = $('#ptitle'); 59const elArtist = $('#partist'); 60const elPlay = $('#play'); 61const elBar = $('#bar'); 62const elBuf = $('#buf'); 63const elProg = $('#progress'); 64const elTime = $('#time'); 65 66/* ---------- helpers ---------- */ 67const fmt = (s) => { 68 if (!s || isNaN(s)) return '0:00'; 69 s = Math.floor(s); 70 return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); 71}; 72function toast(msg, ms = 2600) { 73 let t = $('#toast'); 74 t.innerHTML = msg; 75 t.classList.add('show'); 76 clearTimeout(t._t); 77 t._t = setTimeout(() => t.classList.remove('show'), ms); 78} 79const shuffle = (a) => { for (let i = a.length - 1; i > 0; i--) { const j = (Math.random() * (i + 1)) | 0; [a[i], a[j]] = [a[j], a[i]]; } return a; }; 80const artOf = (t) => (t.artwork && (t.artwork['480x480'] || t.artwork['150x150'] || t.artwork['1000x1000'])) || ''; 81const streamUrl = (t) => `${HOSTS[hostIdx]}/v1/tracks/${t.id}/stream?app_name=${APP}`; 82 83/* fetch with host rotation on failure */ 84async function api(path) { 85 let lastErr; 86 for (let attempt = 0; attempt < HOSTS.length; attempt++) { 87 const base = HOSTS[hostIdx]; 88 try { 89 const sep = path.includes('?') ? '&' : '?'; 90 const res = await fetch(`${base}${path}${sep}app_name=${APP}`, { headers: { Accept: 'application/json' } }); 91 if (!res.ok) throw new Error('HTTP ' + res.status); 92 return await res.json(); 93 } catch (e) { 94 lastErr = e; 95 hostIdx = (hostIdx + 1) % HOSTS.length; // try next node 96 } 97 } 98 throw lastErr; 99} 100 101function usable(t) { 102 return t && t.id && t.is_streamable !== false && !t.is_delete && (t.duration || 0) > 30; 103} 104 105/* Build a playlist for a mood. */ 106async function buildQueue(mood) { 107 let tracks = []; 108 if (mood.trending) { 109 const genres = ['Electronic', 'Hip-Hop/Rap', 'Pop', 'Alternative']; 110 const g = genres[(Math.random() * genres.length) | 0]; 111 const r = await api(`/v1/tracks/trending?genre=${encodeURIComponent(g)}&limit=40`); 112 tracks = (r.data || []).filter(usable); 113 } else { 114 const results = await Promise.allSettled( 115 mood.q.map((q) => api(`/v1/tracks/search?query=${encodeURIComponent(q)}&limit=30`)) 116 ); 117 const seen = new Set(); 118 for (const r of results) { 119 if (r.status !== 'fulfilled') continue; 120 for (const t of (r.value.data || [])) { 121 if (usable(t) && !seen.has(t.id)) { seen.add(t.id); tracks.push(t); } 122 } 123 } 124 // float tracks whose Audius mood matches this tile to the front 125 const set = new Set(mood.m); 126 tracks.sort((a, b) => (set.has(b.mood) ? 1 : 0) - (set.has(a.mood) ? 1 : 0)); 127 const top = tracks.filter((t) => set.has(t.mood)); 128 const rest = shuffle(tracks.filter((t) => !set.has(t.mood))); 129 tracks = [...shuffle(top), ...rest]; 130 } 131 return tracks.slice(0, 40); 132} 133 134/* ---------- playback ---------- */ 135async function playMood(mood, tile) { 136 currentMood = mood; 137 document.querySelectorAll('.mood').forEach((m) => m.classList.remove('playing')); 138 tile.classList.add('playing'); 139 player.classList.add('up'); 140 elTitle.innerHTML = `<span class="spin"></span>Finding ${mood.label.toLowerCase()} tracksâ¦`; 141 elArtist.textContent = 'from the Audius network'; 142 elArt.style.backgroundImage = 'none'; 143 144 try { 145 queue = await buildQueue(mood); 146 } catch (e) { 147 elTitle.textContent = 'Network hiccup'; 148 elArtist.textContent = 'Could not reach Audius â try again'; 149 toast('â ï¸ Could not load tracks. Check your connection.'); 150 return; 151 } 152 if (!queue.length) { 153 elTitle.textContent = 'Nothing found'; 154 elArtist.textContent = 'Try another mood'; 155 return; 156 } 157 qi = -1; 158 next(); 159} 160 161function load(track) { 162 elArt.style.backgroundImage = artOf(track) ? `url("${artOf(track)}")` : 'none'; 163 elTitle.textContent = track.title || 'Untitled'; 164 const moodTag = track.mood ? ` · <span class="badge">${track.mood}</span>` : '';
165 elArtist.innerHTML = `${(track.user && track.user.name) || 'Unknown artist'}${moodTag}`; 166 audio.src = streamUrl(track); 167 audio.play().catch(() => {}); 168 updateMediaSession(track); 169 document.title = `${track.title} â yura.ai music`; 170} 171 172function next() { 173 if (!queue.length) return; 174 qi = (qi + 1) % queue.length; 175 if (qi === 0 && queue.length > 1) shuffle(queue); // reshuffle on loop 176 load(queue[qi]); 177} 178function prev() { 179 if (!queue.length) return; 180 if (audio.currentTime > 4) { audio.currentTime = 0; return; } 181 qi = (qi - 1 + queue.length) % queue.length; 182 load(queue[qi]); 183} 184function toggle() { 185 if (!queue.length) { 186 // nothing chosen yet â pick a random mood 187 const m = MOODS[(Math.random() * (MOODS.length - 1)) | 0]; 188 playMood(m, document.querySelector(`.mood[data-id="${m.id}"]`)); 189 return; 190 } 191 if (audio.paused) audio.play(); else audio.pause(); 192} 193 194/* ---------- media session (lock screen / hardware keys) ---------- */ 195function updateMediaSession(track) { 196 if (!('mediaSession' in navigator)) return; 197 const art = artOf(track); 198 navigator.mediaSession.metadata = new MediaMetadata({ 199 title: track.title || 'Untitled', 200 artist: (track.user && track.user.name) || 'Audius', 201 album: currentMood ? `yura.ai music · ${currentMood.label}` : 'yura.ai music', 202 artwork: art ? [ 203 { src: art, sizes: '480x480', type: 'image/jpeg' }, 204 { src: art, sizes: '150x150', type: 'image/jpeg' } 205 ] : [] 206 }); 207 navigator.mediaSession.setActionHandler('play', () => audio.play()); 208 navigator.mediaSession.setActionHandler('pause', () => audio.pause()); 209 navigator.mediaSession.setActionHandler('nexttrack', next); 210 navigator.mediaSession.setActionHandler('previoustrack', prev); 211} 212 213/* ---------- audio events ---------- */ 214audio.addEventListener('play', () => { elPlay.innerHTML = ICON.pause; }); 215audio.addEventListener('pause', () => { elPlay.innerHTML = ICON.play; }); 216audio.addEventListener('ended', next); 217audio.addEventListener('error', () => { if (queue.length) { toast('Skipping unplayable trackâ¦', 1500); next(); } }); 218audio.addEventListener('timeupdate', () => { 219 const d = audio.duration || 0; 220 elBar.style.width = d ? (audio.currentTime / d) * 100 + '%' : '0%'; 221 elTime.textContent = `${fmt(audio.currentTime)} / ${fmt(d)}`; 222}); 223audio.addEventListener('progress', () => { 224 if (audio.buffered.length && audio.duration) { 225 elBuf.style.width = (audio.buffered.end(audio.buffered.length - 1) / audio.duration) * 100 + '%'; 226 } 227}); 228 229/* ---------- icons ---------- */ 230const ICON = { 231 play: '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>', 232 pause: '<svg viewBox="0 0 24 24"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>', 233 prev: '<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>', 234 next: '<svg viewBox="0 0 24 24"><path d="M16 6h2v12h-2zM6 18l8.5-6L6 6z"/></svg>' 235}; 236 237/* ---------- build UI ---------- */ 238function render() { 239 moodGrid.innerHTML = ''; 240 for (const m of MOODS) { 241 const el = document.createElement('button'); 242 el.className = 'mood'; 243 el.dataset.id = m.id; 244 el.style.setProperty('--grad', m.grad); 245 el.setAttribute('aria-label', `Play ${m.label} mood`); 246 el.innerHTML = ` 247 <div class="eq"><span></span><span></span><span></span><span></span></div> 248 <div class="emoji">${m.emoji}</div> 249 <div class="label">${m.label}</div> 250 <div class="sub">${m.sub}</div>`; 251 el.addEventListener('click', () => playMood(m, el)); 252 moodGrid.appendChild(el); 253 } 254 elPlay.innerHTML = ICON.play; 255 $('#prev').innerHTML = ICON.prev; 256 $('#next').innerHTML = ICON.next; 257} 258 259/* control wiring */ 260elPlay.addEventListener('click', toggle); 261$('#next').addEventListener('click', next); 262$('#prev').addEventListener('click', prev); 263elProg.addEventListener('click', (e) => { 264 if (!audio.duration) return; 265 const r = elProg.getBoundingClientRect(); 266 audio.currentTime = ((e.clientX - r.left) / r.width) * audio.duration; 267}); 268document.addEventListener('keydown', (e) => { 269 if (e.target.tagName === 'INPUT') return; 270 if (e.code === 'Space') { e.preventDefault(); toggle(); } 271 else if (e.code === 'ArrowRight') next(); 272 else if (e.code === 'ArrowLeft') prev(); 273}); 274 275/* ---------- PWA: install prompt + service worker ---------- */ 276let deferredPrompt = null; 277const installBtn = $('#installBtn'); 278window.addEventListener('beforeinstallprompt', (e) => { 279 e.preventDefault(); 280 deferredPrompt = e; 281 installBtn.style.display = 'inline-flex'; 282}); 283installBtn.addEventListener('click', async () => { 284 if (!deferredPrompt) return; 285 deferredPrompt.prompt(); 286 await deferredPrompt.userChoice; 287 deferredPrompt = null; 288 installBtn.style.display = 'none'; 289}); 290window.addEventListener('appinstalled', () => { 291 installBtn.style.display = 'none'; 292 toast('â Installed! Launch yura.ai music from your home screen.'); 293}); 294 295if ('serviceWorker' in navigator) { 296 window.addEventListener('load', () => navigator.serviceWorker.register('./sw.js').catch(() => {})); 297} 298 299render();
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.