PageSourceSearch

https://yura.ai/music/app.js

js yura.ai collected 2026-10-05 13:20:26 UTC 12,293 bytes, 299 lines download raw bytes

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.