/* ── Cadence front-end ─────────────────────────────────────────────────── */ (() => { 'use strict'; const $ = (s, r = document) => r.querySelector(s); const audio = $('#audio'); /* ── state ──────────────────────────────────────────────────────────────── */ const state = { queue: [], // array of track objects (play order) index: -1, // index of the loaded track current: null, viewLists: {}, // named track lists of the rendered view shuffle: false, repeat: 0, // 0 = off, 1 = all, 2 = one growing: false, history: [], lastSearch: { tracks: [], artists: [] }, suggestItems: [], suggestActive: -1, token: 0, // guards against out-of-order async renders }; const LS = { get(k, d) { try { return JSON.parse(localStorage.getItem('cadence:' + k)) ?? d; } catch { return d; } }, set(k, v) { try { localStorage.setItem('cadence:' + k, JSON.stringify(v)); } catch {} }, }; /* ── tiny helpers ───────────────────────────────────────────────────────── */ const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const icon = n => ``; function fmtTime(sec) { if (!sec || !isFinite(sec) || sec < 0) return '0:00'; sec = Math.floor(sec); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`; } function toast(msg, isErr = false, ms = 3200) { const el = document.createElement('div'); el.className = 'toast' + (isErr ? ' err' : ''); el.textContent = msg; $('#toasts').appendChild(el); setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; setTimeout(() => el.remove(), 320); }, ms); } async function api(path, opts) { const r = await fetch(path, opts); let data = null; try { data = await r.json(); } catch {} if (!r.ok) throw new Error((data && data.error) || `Request failed (${r.status})`); return data; } const thumb = vid => `/api/thumb/${vid}`; const artOf = t => t.album && t.album.id ? t.album.id : null; const artistName = t => t.artist_text || 'Unknown artist'; function artistLink(a) { return `${esc(a.name)}`; } /* ── rendering primitives ───────────────────────────────────────────────── */ function trackRows(tracks, listName, startNum = 1, showAlbum = true) { if (!tracks || !tracks.length) return `

No tracks

Try another search.

`; return `
` + tracks.map((t, i) => `
${startNum + i} ${icon('play')}
${esc(t.title)}
${(t.artists || []).map(artistLink).join(', ') || esc(artistName(t))}
${t.album && t.album.id ? `${esc(t.album.name || '')}` : esc((t.album && t.album.name) || '')}
${esc(fmtTime(t.duration))}
`).join('') + `
`; } function skeletons(n = 8) { return Array.from({ length: n }, () => `
`).join(''); } function card({ img, title, sub, attrs, iconName }) { return ``; } /* ── views ──────────────────────────────────────────────────────────────── */ const view = $('#view'); function markNowPlaying() { const vid = state.current && state.current.video_id; document.querySelectorAll('.trow, .qrow').forEach(el => { const on = vid && el.dataset.vid === vid; el.classList.toggle('now', !!on); const num = el.querySelector('.idx .num'); if (num) num.innerHTML = on ? `` : (num.dataset.n || ''); }); } function renderHome() { const recent = LS.get('recent', []); const moods = ['Lo-fi beats', 'Queen', 'Radiohead', '80s hits', 'Taylor Swift', 'Jazz classics', 'Film scores', 'Kendrick Lamar', 'Daft Punk', 'Acoustic covers', 'Piano study', 'Drum & bass']; view.innerHTML = `

Search it. Stream it.
Let the radio take over.

Cadence plays any song by name from YouTube, then keeps the music going with artist radios and similar-track suggestions.

${recent.length ? `

${icon('queue')}Recently played

${trackRows(recent, 'recent')}
` : ''}

${icon('spark')}Start a station

${moods.map(m => ``).join('')}
`; state.viewLists = { recent }; } async function runSearch(q, { render = true } = {}) { if (!q.trim()) return; const token = ++state.token; $('#q').value = q; hideSuggest(); if (render) view.innerHTML = `

${icon('search')}Searching "${esc(q)}"…

${skeletons(9)}
`; try { const data = await api(`/api/search?q=${encodeURIComponent(q)}&limit=24`); if (token !== state.token) return; state.lastSearch = data; renderSearch(data); } catch (e) { if (token !== state.token) return; view.innerHTML = `

Search failed

${esc(e.message)}

`; } } function renderSearch(data) { const artists = data.artists || []; const tracks = data.tracks || []; view.innerHTML = ` ${artists.length ? `

${icon('artist')}Artists

${artists.slice(0, 8).map(a => card({ img: a.thumbnail ? `/api/img?u=${encodeURIComponent(a.thumbnail)}` : '', title: a.name, sub: a.subscribers ? `${a.subscribers} subscribers` : 'Artist', attrs: `data-artist data-artist-id="${esc(a.id)}" data-artist-name="${esc(a.name)}"`, iconName: 'artist', })).join('')}
` : ''}

${icon('play')}Songs${tracks.length ? ` (${tracks.length})` : ''}

${trackRows(tracks, 'search')}
`; state.viewLists = { search: tracks }; markNowPlaying(); } async function openArtist({ id, name }) { const token = ++state.token; view.innerHTML = `
Artist

${esc(name || '…')}

Loading…
${skeletons(8)}`; try { const qs = (id && id !== 'undefined') ? `id=${encodeURIComponent(id)}` : `name=${encodeURIComponent(name || '')}`; const a = await api(`/api/artist?${qs}`); if (token !== state.token) return; const listeners = a.monthly_listeners ? `${Number(a.monthly_listeners).toLocaleString()} monthly listeners` : ''; view.innerHTML = `
Artist

${esc(a.name)}

${[a.subscribers ? `${a.subscribers} subscribers` : '', listeners].filter(Boolean).join(' · ')}
${a.description ? `

${esc(a.description)}

` : ''}
${a.top_tracks.length ? `` : ''}

${icon('play')}Top songs

${trackRows(a.top_tracks, 'top')}
${a.similar && a.similar.length ? `

${icon('artist')}Fans also like

${a.similar.map(s => card({ title: s.name, sub: 'Artist', attrs: `data-artist data-artist-id="${esc(s.id)}" data-artist-name="${esc(s.name)}"`, iconName: 'artist' })).join('')}
` : ''} ${a.albums && a.albums.length ? `

${icon('queue')}Albums & singles

${a.albums.map(al => card({ title: al.title, sub: [al.year, al.type].filter(Boolean).join(' · '), attrs: `data-album="${esc(al.id)}"` })).join('')}
` : ''}`; state.viewLists = { top: a.top_tracks }; state.artist = a; markNowPlaying(); } catch (e) { if (token !== state.token) return; view.innerHTML = `

Couldn't load artist

${esc(e.message)}

`; } } async function openAlbum(id) { const token = ++state.token; view.innerHTML = `

${icon('queue')}Loading album…

${skeletons(8)}
`; try { const al = await api(`/api/album?id=${encodeURIComponent(id)}`); if (token !== state.token) return; view.innerHTML = `
Album

${esc(al.title)} ${al.year ? `· ${al.year}` : ''}

${trackRows(al.tracks, 'album')}
`; state.viewLists = { album: al.tracks }; markNowPlaying(); } catch (e) { if (token !== state.token) return; view.innerHTML = `

Couldn't load album

${esc(e.message)}

`; } } const LABELS = { search: 'Search results', top: 'Top songs', album: 'Album', recent: 'Recently played' }; /* ── playback ───────────────────────────────────────────────────────────── */ function setQueue(list, startIndex, label) { state.queue = list.slice(); state.queueSource = list; state.index = startIndex; state.radioLabel = label || null; refreshQueue(label); } function playTrackAt(i, { addToHistory = true } = {}) { if (i < 0 || i >= state.queue.length) return; const t = state.queue[i]; if (addToHistory && state.current) state.history.push(state.index); state.index = i; state.current = t; $('#npTitle').textContent = t.title; $('#npArtist').innerHTML = (t.artists || []).map(artistLink).join(', ') || esc(artistName(t)); $('#npArt').innerHTML = ``; $('#radioBadge').hidden = !state.radioLabel; $('#addCurrent').hidden = false; document.title = `${t.title} · ${artistName(t)} — Cadence`; setLoading(true); audio.src = `/api/stream/${t.video_id}`; audio.play().catch(err => { setLoading(false); if (err && err.name !== 'AbortError') toast('Playback blocked — press play to start', true); }); const next = state.queue[i + 1]; if (next) fetch(`/api/prefetch/${next.video_id}`, { method: 'POST' }).catch(() => {}); remember(t); markNowPlaying(); refreshQueue(); maybeGrow(); } function remember(t) { const recent = LS.get('recent', []).filter(x => x.video_id !== t.video_id); recent.unshift({ video_id: t.video_id, title: t.title, artists: t.artists, artist_text: t.artist_text, album: t.album, duration: t.duration }); LS.set('recent', recent.slice(0, 12)); } function togglePlay() { if (!state.current) { toast('Pick a song first 🙂'); return; } if (audio.paused) audio.play().catch(() => {}); else audio.pause(); } function next(auto = false) { if (!state.queue.length) return; if (state.repeat === 2 && auto) { audio.currentTime = 0; audio.play(); return; } let i; if (state.shuffle) { do { i = Math.floor(Math.random() * state.queue.length); } while (state.queue.length > 1 && i === state.index); } else { i = state.index + 1; } if (i >= state.queue.length) { if (state.repeat === 1 || auto) { // keep the stream alive: grow via radio, otherwise loop growQueue().then(grew => { if (grew) playTrackAt(state.index + 1); else if (state.repeat === 1) playTrackAt(0); else { toast('End of queue'); audio.pause(); } }); return; } playTrackAt(0); return; } playTrackAt(i); } function prev() { if (audio.currentTime > 4 || !state.queue.length) { audio.currentTime = 0; return; } playTrackAt(Math.max(0, state.index - 1)); } /* radio: keep the queue fed from the track that is playing */ async function growQueue() { if (state.growing) return false; const seed = (state.current && state.current.video_id) || null; if (!seed) return false; state.growing = true; try { const data = await api(`/api/radio?videoId=${seed}&limit=22`); const have = new Set(state.queue.map(t => t.video_id)); const fresh = (data.tracks || []).filter(t => !have.has(t.video_id)); if (!fresh.length) return false; state.queue.push(...fresh); state.radioLabel = 'Radio · ' + (state.current ? artistName(state.current) : 'station'); refreshQueue(); $('#radioBadge').hidden = false; return true; } catch (e) { toast('Couldn\'t load suggestions: ' + e.message, true); return false; } finally { state.growing = false; } } function maybeGrow() { if (state.shuffle || state.repeat === 2) return; if (state.queue.length - state.index <= 3) growQueue(); } /* "similar songs": seed the queue with this track + its radio suggestions */ async function playSimilar(t) { toast(`Finding songs similar to "${t.title}"…`); try { const data = await api(`/api/radio?videoId=${t.video_id}&limit=25`); const rest = (data.tracks || []).filter(x => x.video_id !== t.video_id); if (!rest.length) throw new Error('no suggestions came back'); setQueue([t, ...rest], 0, `Similar to ${t.title}`); playTrackAt(0); toast(`▶ ${rest.length} songs similar to ${artistName(t)} queued`); } catch (e) { toast('Couldn\'t get similar songs: ' + e.message, true); } } async function playArtistRadio({ id, name }) { toast(`Tuning ${name} radio…`); try { const data = await api(`/api/artist/radio?id=${encodeURIComponent(id)}&limit=30`); const tracks = data.tracks || []; if (!tracks.length) throw new Error('station came back empty'); setQueue(tracks, 0, `Artist radio · ${data.station_name || name}`); playTrackAt(0); toast(`▶ ${data.station_name || name} radio — ${tracks.length} tracks`); } catch (e) { toast('Radio failed: ' + e.message, true); } } /* ── queue panel ────────────────────────────────────────────────────────── */ function refreshQueue(label) { const list = $('#queueList'); if (!state.queue.length) { list.innerHTML = `
Nothing queued yet.
Play a song and Cadence will suggest what's next.
`; $('#queueSub').textContent = 'Suggestions appear as you listen'; return; } $('#queueTitle').textContent = label || state.radioLabel || 'Up next'; $('#queueSub').textContent = `Track ${state.index + 1} of ${state.queue.length}`; list.innerHTML = state.queue.map((t, i) => `
  • ${esc(t.title)}
    ${esc(artistName(t))}
    ${esc(fmtTime(t.duration))}
  • `).join(''); } /* ── audio element plumbing ─────────────────────────────────────────────── */ function setLoading(on) { $('#spinner').hidden = !on; } audio.addEventListener('playing', () => { setLoading(false); setPlayIcon(true); markNowPlaying(); growAhead(); }); audio.addEventListener('pause', () => setPlayIcon(false)); audio.addEventListener('waiting', () => setLoading(true)); audio.addEventListener('canplay', () => setLoading(false)); audio.addEventListener('timeupdate', () => { const d = audio.duration || (state.current && state.current.duration) || 0; const p = d ? (audio.currentTime / d) : 0; $('#tCur').textContent = fmtTime(audio.currentTime); $('#tDur').textContent = fmtTime(d); if (!seeking) { $('#seek').value = Math.round(p * 1000); $('#seekFill').style.width = (p * 100) + '%'; } }); audio.addEventListener('ended', () => next(true)); let errStreak = 0; audio.addEventListener('error', () => { setLoading(false); if (!state.current) return; if (++errStreak > 3) { toast('Several tracks failed to stream — pausing', true); audio.pause(); return; } toast(`Couldn't stream "${state.current.title}" — skipping`, true); setTimeout(() => next(true), 700); }); audio.addEventListener('playing', () => { errStreak = 0; }); audio.volume = LS.get('volume', 0.9); $('#vol').value = audio.volume; function setPlayIcon(playing) { $('#playBtn').innerHTML = icon(playing ? 'pause' : 'play'); $('#playBtn').title = playing ? 'Pause (Space)' : 'Play (Space)'; } setPlayIcon(false); function growAhead() { // room remaining in queue? top it up with similar tracks in the background if (state.queue.length - state.index <= 3) growQueue(); } /* seek */ let seeking = false; const seek = $('#seek'); ['input', 'change'].forEach(ev => seek.addEventListener(ev, () => { seeking = ev === 'input' ? true : seeking; const d = audio.duration || 0; if (d) { const t = (seek.value / 1000) * d; $('#tCur').textContent = fmtTime(t); $('#seekFill').style.width = (seek.value / 10) + '%'; $('#tDur').textContent = fmtTime(d); } if (ev === 'change') { if (d) audio.currentTime = (seek.value / 1000) * d; seeking = false; } })); seek.addEventListener('pointerup', () => { seeking = false; }); /* volume / mute */ $('#vol').addEventListener('input', e => { audio.volume = Number(e.target.value); if (audio.volume > 0) audio.muted = false; LS.set('volume', audio.volume); syncMute(); }); function syncMute() { $('#muteBtn').innerHTML = icon(audio.muted || audio.volume === 0 ? 'mute' : 'vol'); } $('#muteBtn').addEventListener('click', () => { audio.muted = !audio.muted; syncMute(); }); syncMute(); /* transport buttons */ $('#playBtn').addEventListener('click', togglePlay); $('#nextBtn').addEventListener('click', () => next(false)); $('#prevBtn').addEventListener('click', prev); $('#shuffleBtn').addEventListener('click', e => { state.shuffle = !state.shuffle; e.currentTarget.classList.toggle('on', state.shuffle); toast(state.shuffle ? 'Shuffle on' : 'Shuffle off'); }); $('#repeatBtn').addEventListener('click', e => { state.repeat = (state.repeat + 1) % 3; e.currentTarget.classList.toggle('on', state.repeat > 0); e.currentTarget.innerHTML = icon(state.repeat === 2 ? 'repeat1' : 'repeat'); toast(['Repeat off', 'Repeat queue', 'Repeat track'][state.repeat]); }); $('#addCurrent').addEventListener('click', () => { if (!state.current) return; state.queue.push(state.current); refreshQueue(); toast('Re-queued ' + state.current.title); }); /* ── events: view ───────────────────────────────────────────────────────── */ view.addEventListener('click', e => { const artistEl = e.target.closest('[data-artist]'); if (artistEl) { e.preventDefault(); openArtist({ id: artistEl.dataset.artistId || '', name: artistEl.dataset.artistName || '' }); return; } const albumEl = e.target.closest('[data-album]'); if (albumEl) { e.preventDefault(); openAlbum(albumEl.dataset.album); return; } const act = e.target.closest('[data-action]'); if (act) { const a = act.dataset.action; if (a === 'queue') { e.stopPropagation(); const row = e.target.closest('.trow'); const list = state.viewLists[row.dataset.list] || []; const t = list[Number(row.dataset.i)]; if (t) { state.queue.push(t); refreshQueue(); toast(`Added "${t.title}" to queue`); } return; } if (a === 'similar') { e.stopPropagation(); const row = e.target.closest('.trow'); const list = state.viewLists[row.dataset.list] || []; const t = list[Number(row.dataset.i)]; if (t) playSimilar(t); return; } if (a === 'play-list') { const list = state.viewLists[act.dataset.list] || []; if (list.length) { setQueue(list, 0, 'Playing list'); playTrackAt(0); } return; } if (a === 'artist-radio') { playArtistRadio({ id: act.dataset.artistId, name: act.dataset.artistName }); return; } if (a === 'surprise') { const picks = ['Lo-fi hip hop', 'Daft Punk', 'Fleetwood Mac', 'Kendrick Lamar', 'Tame Impala', 'Nina Simone', 'The Weeknd', 'Radiohead']; runSearch(picks[Math.floor(Math.random() * picks.length)]); return; } if (a === 'focus-search') { $('#q').focus(); return; } } const chip = e.target.closest('[data-search]'); if (chip) { runSearch(chip.dataset.search); return; } const row = e.target.closest('.trow'); if (row) { const listName = row.dataset.list; const list = state.viewLists[listName] || []; const i = Number(row.dataset.i); if (list.length) { const label = LABELS[listName] || 'Playing list'; if (state.queueSource !== list) setQueue(list, i, label); playTrackAt(i); } } }); /* ── events: queue panel ────────────────────────────────────────────────── */ $('#queueList').addEventListener('click', e => { const row = e.target.closest('.qrow'); if (row) playTrackAt(Number(row.dataset.qi)); }); $('#clearQueue').addEventListener('click', () => { const keep = state.current; state.queue = keep ? [keep] : []; state.index = keep ? 0 : -1; state.radioLabel = null; $('#radioBadge').hidden = true; refreshQueue(); toast('Queue cleared'); }); $('#queueToggle').addEventListener('click', e => { const isNarrow = window.matchMedia('(max-width:900px)').matches; const panel = $('#queuePanel'); if (isNarrow) { panel.classList.toggle('open'); } else { $('.content').classList.toggle('queue-hidden'); } e.currentTarget.classList.toggle('active'); }); /* ── search box + suggestions ───────────────────────────────────────────── */ let searchTimer = null, suggestTimer = null; const qInput = $('#q'); qInput.addEventListener('input', () => { const v = qInput.value.trim(); clearTimeout(suggestTimer); if (v.length < 2) { hideSuggest(); return; } suggestTimer = setTimeout(fetchSuggestions, 160); }); qInput.addEventListener('keydown', e => { const box = $('#suggest'); const open = !box.hidden && state.suggestItems.length; if (e.key === 'ArrowDown' && open) { e.preventDefault(); state.suggestActive = Math.min(state.suggestActive + 1, state.suggestItems.length - 1); paintSuggest(); } else if (e.key === 'ArrowUp' && open) { e.preventDefault(); state.suggestActive = Math.max(state.suggestActive - 1, -1); paintSuggest(); } else if (e.key === 'Enter') { if (open && state.suggestActive >= 0) runSearch(state.suggestItems[state.suggestActive]); else runSearch(qInput.value); } else if (e.key === 'Escape') hideSuggest(); }); async function fetchSuggestions() { const v = qInput.value.trim(); if (v.length < 2) return; try { const data = await api(`/api/suggest?q=${encodeURIComponent(v)}`); if (qInput.value.trim() !== v) return; state.suggestItems = data.suggestions || []; state.suggestActive = -1; paintSuggest(); } catch {} } function paintSuggest() { const box = $('#suggest'); if (!state.suggestItems.length) { hideSuggest(); return; } box.innerHTML = state.suggestItems.map((s, i) => `
    ${icon('search')}${esc(s)}
    `).join(''); box.hidden = false; } function hideSuggest() { $('#suggest').hidden = true; state.suggestActive = -1; } $('#suggest').addEventListener('mousedown', e => { const el = e.target.closest('[data-s]'); if (el) { e.preventDefault(); runSearch(el.dataset.s); } }); document.addEventListener('click', e => { if (!e.target.closest('.searchwrap')) hideSuggest(); }); /* ── keyboard shortcuts ─────────────────────────────────────────────────── */ document.addEventListener('keydown', e => { const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName); if (e.key === '/' && !typing) { e.preventDefault(); qInput.focus(); qInput.select(); return; } if (typing || e.metaKey || e.ctrlKey || e.altKey) return; switch (e.key.toLowerCase()) { case ' ': e.preventDefault(); togglePlay(); break; case 'n': next(false); break; case 'p': prev(); break; case 's': $('#shuffleBtn').click(); break; case 'r': $('#repeatBtn').click(); break; case 'm': $('#muteBtn').click(); break; case 'q': $('#queueToggle').click(); break; case 'arrowright': audio.currentTime = Math.min((audio.duration || 0), audio.currentTime + 10); break; case 'arrowleft': audio.currentTime = Math.max(0, audio.currentTime - 10); break; } }); /* media session (lockscreen / headset buttons) */ if ('mediaSession' in navigator) { navigator.mediaSession.setActionHandler('play', togglePlay); navigator.mediaSession.setActionHandler('pause', togglePlay); navigator.mediaSession.setActionHandler('nexttrack', () => next(false)); navigator.mediaSession.setActionHandler('previoustrack', prev); } function updateMediaSession(t) { if (!('mediaSession' in navigator) || !t) return; navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: artistName(t), album: (t.album && t.album.name) || 'Cadence', artwork: [{ src: thumb(t.video_id), sizes: '320x180', type: 'image/jpeg' }], }); } const _playTrackAt = playTrackAt; playTrackAt = function (i, opts) { _playTrackAt(i, opts); updateMediaSession(state.current); }; /* ── boot ───────────────────────────────────────────────────────────────── */ $('#brandLink').addEventListener('click', e => { e.preventDefault(); state.token++; history.replaceState(null, '', '/'); renderHome(); }); window.addEventListener('popstate', () => renderHome()); renderHome(); console.log('%c🎧 Cadence ready', 'color:#a78bfa;font-weight:bold'); })();