/* ── Cadence dashboard ───────────────────────────────────────────────────── Views: overview · search · trending · browse (genres/moods) · artist · album playlist · recent Engine stats + buffer-ahead telemetry. ──────────────────────────────────────────────────────────────────────── */ (() => { 'use strict'; const $ = (s, r = document) => r.querySelector(s); const audio = $('#audio'); const main = $('#main'); /* ── state ──────────────────────────────────────────────────────────────── */ const state = { queue: [], queueSource: null, index: -1, current: null, shuffle: false, repeat: 0, queueLabel: null, station: null, growing: false, suggestionsAdded: 0, viewLists: {}, artistCache: {}, country: localStorage.getItem('cadence:country') || 'CH', token: 0, buffered: { id: null, ready: false, bytes: 0 }, }; const LABELS = { search: 'Search results', top: 'Top songs', album: 'Album', recent: 'Recently played', playlist: 'Playlist', trending: 'Trending', over_rec: 'Recently played', feed: 'Suggestions' }; 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 {} }, }; /* ── helpers ────────────────────────────────────────────────────────────── */ const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const icon = n => ``; const thumb = vid => `/api/thumb/${vid}`; const imgp = u => `/api/img?u=${encodeURIComponent(u)}`; const artistName = t => (t && (t.artist_text || 'Unknown artist')) || 'Unknown artist'; 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')}`; } const fmtBytes = b => { if (!b) return '0 B'; const u = ['B', 'KB', 'MB', 'GB']; const i = Math.min(Math.floor(Math.log(b) / Math.log(1024)), 3); return `${(b / 1024 ** i).toFixed(i ? 1 : 0)} ${u[i]}`; }; const fmtDur = s => { s = Math.round(s || 0); const m = Math.floor(s / 60); return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m ${s % 60}s`; }; 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) { const err = new Error((data && data.error) || `Request failed (${r.status})`); err.status = r.status; err.code = data && data.code; err.hint = data && data.hint; throw err; } return data; } // A dead element tells us nothing useful, so ask the server what went wrong. let blockingReported = false; async function diagnoseStream(id) { try { const r = await fetch(`/api/stream/${id}`, { headers: { Range: 'bytes=0-1' } }); if (r.ok) return { kind: 'transient' }; let d = {}; try { d = await r.json(); } catch {} return { kind: d.code || 'unknown', message: d.error, hint: d.hint }; } catch { return { kind: 'offline' }; } } function reportBlocked(info) { if (blockingReported) return; blockingReported = true; toast(`${info.message || 'Playback is blocked'} ${info.hint ? '— ' + info.hint : ''}`, true, 12000); const badge = $('#radioBadge'); if (badge) { badge.hidden = false; badge.textContent = 'streaming blocked'; } } const artistHref = a => `#/artist/${a.id ? encodeURIComponent(a.id) : ''}?name=${encodeURIComponent(a.name || '')}`; // On touch screens a tap on a track row gets "adjusted" by the browser to the nearest // link — so the artist name inside a row would hijack taps meant to play the song. // Rows stay plain text on touch; artists remain reachable via cards and the hero buttons. const COARSE_POINTER = !!(window.matchMedia && window.matchMedia('(hover: none)').matches); function artistLink(a) { if (COARSE_POINTER) return esc(a.name); // real hash link: shareable + right-clickable, and works even when the API gives no browse id return `${esc(a.name)}`; } /* ── shared renderers ───────────────────────────────────────────────────── */ function trackRows(tracks, listName, opts = {}) { if (!tracks || !tracks.length) return `No tracks here.`; const showAlbum = opts.showAlbum !== false; return `` + tracks.map((t, i) => ` ${opts.startNum ? opts.startNum + i : i + 1} ${icon('play')} ${esc(t.title)} ${(t.artists || []).map(artistLink).join(', ') || esc(artistName(t))} ${showAlbum ? `${t.album && t.album.id ? `${esc(t.album.name || '')}` : esc((t.album && t.album.name) || '')}` : ''} ${icon('spark')} ${icon('plus')} ${esc(fmtTime(t.duration))} `).join('') + ``; } function skeletons(n = 8) { return Array.from({ length: n }, () => ``).join(''); } const initials = name => (name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase(); function artistCard(a) { const src = a.thumbnail ? imgp(a.thumbnail) : ''; const pic = src ? `${esc(initials(a.name))}` : `${esc(initials(a.name))}`; const trend = a.trend === 'up' ? ' ▲' : a.trend === 'down' ? ' ▼' : ''; return ` ${pic} ${esc(a.name)} ${a.rank ? `#${a.rank} · ` : ''}${esc(a.subscribers || 'Artist')}${trend} `; } function markNowPlaying() { const vid = state.current && state.current.video_id; document.querySelectorAll('.trow, .qrow, .feed-row').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 || ''); }); } /* ── router ─────────────────────────────────────────────────────────────── */ const routes = { overview: () => renderOverview(), search: q => renderSearchView(q.get('q') || ''), trending: () => renderTrendingView(), browse: () => renderBrowse(), genre: (q, path) => renderGenre(path, q.get('title') || ''), playlist: (q, path) => renderPlaylist(path, q.get('title') || ''), album: (q, path) => renderAlbum(path), artist: (q, path) => renderArtistView(path, q.get('name') || ''), recent: () => renderRecentView(), }; function go(route) { location.hash = '#' + route; } function currentRoute() { const h = location.hash.replace(/^#\/?/, '') || 'overview'; return '/' + h; } function renderRoute() { const raw = location.hash.replace(/^#\/?/, '') || 'overview'; const [pathPart, queryPart] = raw.split('?'); const segs = pathPart.split('/').filter(Boolean); const name = segs[0] || 'overview'; const arg = segs[1] ? decodeURIComponent(segs.slice(1).join('/')) : ''; const q = new URLSearchParams(queryPart || ''); const fn = routes[name] || routes.overview; document.querySelectorAll('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.nav === name)); main.scrollTop = 0; fn(q, arg); } /* ── overview ───────────────────────────────────────────────────────────── */ async function renderOverview() { const token = ++state.token; const recent = LS.get('recent', []).slice(0, 6); main.innerHTML = ` ${statNowPlaying()} ${statQueue()} ${statBuffer()} ${statRadio()} ${icon('play')}Now playing${icon('spark')}Radio ${nowPlayingHero()} ${icon('spark')}Suggestion feed ${icon('spark')}Load more suggestions ${icon('play')}Trending in … Open charts ${skeletons(5)} ${icon('artist')}Trending artists ${skeletons(4)} ${recent.length ? ` ${icon('queue')}Recently played See all ${trackRows(recent, 'over_rec', { showAlbum: false })} ` : ''}`; state.viewLists.over_rec = recent; paintFeed(); paintNowPlaying(); $('#moreSuggestions').addEventListener('click', async e => { e.currentTarget.disabled = true; const grew = await growQueue(true); e.currentTarget.disabled = false; if (!grew) toast('No more suggestions for this track', true); }); main.querySelectorAll('[data-nav-to]').forEach(b => b.addEventListener('click', () => go('/' + b.dataset.navTo))); // trending panels try { const data = await api(`/api/trending?country=${state.country}&limit=12`); if (token !== state.token) return; fillCountrySelect(data.countries); const region = $('#trendRegion'); if (region) region.textContent = data.country_name; const mini = $('#trendMini'); if (mini) { state.viewLists.trending = data.tracks; mini.innerHTML = trackRows(data.tracks.slice(0, 5), 'trending', { startNum: 1, showAlbum: false }); } const strip = $('#artistMini'); if (strip) strip.innerHTML = data.artists.slice(0, 8).map(artistCard).join('') || 'No chart data'; markNowPlaying(); } catch (e) { const mini = $('#trendMini'); if (mini) mini.innerHTML = `Charts unavailable — ${esc(e.message)}`; const strip = $('#artistMini'); if (strip) strip.innerHTML = ''; } } function nowPlayingHero() { const t = state.current; if (!t) return `${icon('artist')} Nothing playing Search for a song, or start a station from the panels below. Tip: press / to jump to search, space to play/pause. Tap a row to play, or open Trending and Genres below. ${icon('spark')} Surprise me `; return ` ${icon('play')}Loading… ${state.station ? `${icon('spark')}${esc(state.station)}` : ''} ${esc(t.title)} ${(t.artists || []).map(artistLink).join(', ') || esc(artistName(t))} ${esc(t.album && t.album.name || '')}${t.duration ? ` · ${fmtTime(t.duration)}` : ''} ${icon('play')} Play ${icon('spark')} Play similar songs ${firstArtistId(t) ? `${icon('spark')} Artist radio` : ''} ${firstArtistId(t) ? `${icon('artist')} Open artist` : ''} `; } function firstArtistId(t) { return ((t && t.artists) || []).find(a => a.id)?.id || null; } /* stat tiles */ function statNowPlaying() { const t = state.current; return `${icon('play')}Now playing ${t ? esc(t.title) : '—'} ${t ? esc(artistName(t)) : 'nothing loaded'}`; } function statQueue() { return `${icon('queue')}Queue ${state.queue.length} tracks ${state.current ? `track ${state.index + 1} of ${state.queue.length}` : 'idle'}`; } function statBuffer() { return `${icon('play')}Buffered ahead – waiting for playback`; } function statRadio() { return `${icon('spark')}Radio engine ${state.suggestionsAdded} suggested ${state.radioLabel ? esc(state.radioLabel) : 'no station yet'}`; } function paintNowPlaying() { const token = state.token; const body = $('#npBody'); if (body) body.innerHTML = nowPlayingHero(); const tn = $('#tnTitle'), ts = $('#tnSub'); if (tn) tn.textContent = state.current ? state.current.title : '—'; if (ts) ts.textContent = state.current ? artistName(state.current) : 'nothing loaded'; updateHeroState(); paintStats(); void token; } function updateHeroState() { const el = $('#heroState'), label = $('#heroBtnLabel'); if (el) el.textContent = state.current ? (audio.paused ? 'Paused' : 'Playing') : ''; if (label) label.textContent = audio.paused ? 'Play' : 'Pause'; } function paintStats() { const q = $('#tqMain'); if (q) { q.textContent = `${state.queue.length} tracks`; $('#tqSub').textContent = state.current ? `track ${state.index + 1} of ${state.queue.length}` : 'idle'; } const r = $('#trMain'); if (r) { r.textContent = `${state.suggestionsAdded} suggested`; $('#trSub').textContent = state.station || state.queueLabel || 'no station yet'; } const badge = $('#npBadge'); if (badge) badge.hidden = !state.station; const tb = $('#tbMain'), tbs = $('#tbSub'); if (tb && tbs) { if (!state.buffered.id) { tb.textContent = '–'; tbs.textContent = 'waiting for playback'; } else if (state.buffered.ready) { tb.textContent = 'Ready ✓'; tbs.textContent = `${fmtBytes(state.buffered.bytes)} cached for the next track`; $('.tile')?.classList.add('ok'); $('#tileBuf')?.classList.add('ok'); } else { tb.textContent = 'Buffering…'; tbs.textContent = 'next track is being fetched'; $('#tileBuf')?.classList.remove('ok'); } } const heroFacts = $('#heroFacts'); if (heroFacts && state.current) { const d = audio.duration || state.current.duration || 0; heroFacts.textContent = [state.current.album && state.current.album.name, d ? fmtTime(d) : '', audio.currentTime ? `${fmtTime(audio.currentTime)} elapsed` : ''] .filter(Boolean).join(' · '); } } /* suggestion feed */ function paintFeed() { const body = $('#feedBody'); if (!body) return; const upcoming = state.queue.slice(state.index + 1, state.index + 8); const count = $('#feedCount'); if (count) count.textContent = state.queue.length > state.index + 1 ? `${state.queue.length - state.index - 1} in queue` : ''; if (!upcoming.length) { body.innerHTML = `${state.current ? 'Queue is running low — suggestions will appear in a moment.' : 'Play something and Cadence will suggest what comes next.'}`; return; } const curArtist = state.current ? artistName(state.current) : ''; body.innerHTML = `` + upcoming.map((t, i) => { const sameArtist = state.current && artistName(t) === curArtist; const reason = i === 0 ? 'Up next' : sameArtist ? `More from ${esc(artistName(t))}` : `Similar to ${esc(curArtist)}`; const qi = state.index + 1 + i; return ` ${esc(t.title)} ${esc(artistName(t))}${reason} ${esc(fmtTime(t.duration))} `; }).join('') + ``; markNowPlaying(); } /* ── search ─────────────────────────────────────────────────────────────── */ async function renderSearchView(query) { const token = ++state.token; if (!query) { main.innerHTML = `SearchFind any song, artist, album or playlist on YouTube Music. ${['Queen', 'Daft Punk', 'Drake', 'Lo-fi beats', 'Nina Simone', 'Coldplay', 'Beyoncé', 'Pink Floyd', 'Bad Bunny', 'Jazz classics'] .map(s => `${esc(s)}`).join('')} `; return; } main.innerHTML = `“${esc(query)}”Searching YouTube Music…${skeletons(8)}`; try { const data = await api(`/api/search?q=${encodeURIComponent(query)}&limit=24`); if (token !== state.token) return; const tracks = data.tracks || [], artists = data.artists || []; // Nothing at all: say so plainly, and offer a way forward instead of an empty page if (!tracks.length && !artists.length) { const chips = (data.suggestions && data.suggestions.length) ? data.suggestions : ['daft punk', 'taylor swift', 'jazz classics', 'lo-fi beats']; main.innerHTML = ` “${esc(query)}”No matches ${icon('search')} Nothing found for “${esc(query)}” ${window.__SRC__ && window.__SRC__.mode === 'demo' ? 'This file is running in demo mode, so it can only search its built-in library. Connect a backend to search all of YouTube Music.' : 'Try a different spelling, or one of these searches:'} ${chips.map(c => `${esc(c)}`).join('')} ${window.__SRC__ && window.__SRC__.mode === 'demo' ? 'Connect a backend →' : ''} `; return; } state.viewLists.search = tracks; const count = `${tracks.length} ${tracks.length === 1 ? 'song' : 'songs'}${artists.length ? ` · ${artists.length} ${artists.length === 1 ? 'artist' : 'artists'}` : ''}`; main.innerHTML = ` “${esc(query)}”${count} ${artists.length ? ` ${icon('artist')}Artists ${artists.length} ${artists.slice(0, 10).map(artistCard).join('')}` : ''} ${icon('play')}Songs ${tracks.length ? `${icon('play')}Play all` : ''} ${tracks.length ? trackRows(tracks, 'search') : 'No songs matched — try the artists above.'} `; markNowPlaying(); } catch (e) { if (token !== state.token) return; main.innerHTML = `“${esc(query)}”Search failed ${icon('search')} Couldn't run that search ${esc(e.message)} Try again `; } } /* ── trending ───────────────────────────────────────────────────────────── */ async function renderTrendingView() { const token = ++state.token; main.innerHTML = `TrendingYouTube Music charts${skeletons(8)}`; try { const data = await api(`/api/trending?country=${state.country}&limit=24`); if (token !== state.token) return; fillCountrySelect(data.countries); state.viewLists.trending = data.tracks; main.innerHTML = ` Trending in ${esc(data.country_name)}Updated from the YouTube Music charts ${icon('play')}Play the chart ${icon('play')}Charting tracks${data.tracks.length} tracks ${trackRows(data.tracks, 'trending')} ${icon('artist')}Trending artists ${data.artists.map(artistCard).join('')} `; markNowPlaying(); } catch (e) { if (token !== state.token) return; main.innerHTML = `Charts unavailable — ${esc(e.message)}`; } } function fillCountrySelect(options) { const sel = $('#countrySel'); if (!sel || !options || !options.length) return; const names = { ZZ: '🌍 Global', CH: '🇨🇭 Switzerland', US: '🇺🇸 United States', GB: '🇬🇧 United Kingdom', DE: '🇩🇪 Germany', FR: '🇫🇷 France', IT: '🇮🇹 Italy', ES: '🇪🇸 Spain', AT: '🇦🇹 Austria', NL: '🇳🇱 Netherlands', SE: '🇸🇪 Sweden', JP: '🇯🇵 Japan', KR: '🇰🇷 South Korea', BR: '🇧🇷 Brazil', CA: '🇨🇦 Canada', AU: '🇦🇺 Australia', IN: '🇮🇳 India', MX: '🇲🇽 Mexico', PL: '🇵🇱 Poland', PT: '🇵🇹 Portugal' }; if (sel.dataset.filled === options.join(',')) return; sel.innerHTML = options.map(c => `${esc(names[c] || c)}`).join(''); sel.dataset.filled = options.join(','); sel.value = state.country; } $('#countrySel').addEventListener('change', e => { state.country = e.target.value; localStorage.setItem('cadence:country', state.country); if ((currentRoute().includes('trending')) || currentRoute() === '/overview') renderRoute(); }); /* ── browse: genres & moods ─────────────────────────────────────────────── */ const GENRE_COLORS = [['#8b5cf6', '#22d3ee'], ['#f472b6', '#8b5cf6'], ['#22d3ee', '#4ade80'], ['#fbbf24', '#f472b6'], ['#60a5fa', '#a78bfa'], ['#4ade80', '#22d3ee']]; async function renderBrowse() { const token = ++state.token; main.innerHTML = `Genres & moodsBrowse YouTube Music moods, moments and genres${skeletons(6)}`; try { const data = await api('/api/genres'); if (token !== state.token) return; let ci = 0; main.innerHTML = `Genres & moodsPick a vibe — every tile leads to ready-made playlists` + data.categories.map(cat => ` ${icon('queue')}${esc(cat.title)}${cat.items.length} tiles ${cat.items.map(it => { const [a, b] = GENRE_COLORS[ci++ % GENRE_COLORS.length]; return ` ${esc(it.title)} Open playlists →`; }).join('')} `).join(''); } catch (e) { if (token !== state.token) return; main.innerHTML = `Couldn't load genres — ${esc(e.message)}`; } } async function renderGenre(params, title) { const token = ++state.token; main.innerHTML = `Genres & moods› ${esc(title)}${skeletons(6)}`; try { const data = await api(`/api/genre?params=${encodeURIComponent(params)}`); if (token !== state.token) return; main.innerHTML = ` Genres & moods› ${esc(title)} ${esc(title)}${data.playlists.length} playlists curated by YouTube Music ${data.playlists.map(p => ` ${p.thumbnail ? `` : ''} ${esc(p.title)} ${esc(p.count || '')} ${p.count ? 'songs' : 'playlist'} `).join('')}`; } catch (e) { if (token !== state.token) return; main.innerHTML = `Couldn't load playlists — ${esc(e.message)}`; } } async function renderPlaylist(id, title) { const token = ++state.token; main.innerHTML = `Genres & moods› ${esc(title || 'Playlist')}${skeletons(8)}`; try { const data = await api(`/api/playlist?id=${encodeURIComponent(id)}&limit=80`); if (token !== state.token) return; state.viewLists.playlist = data.tracks; main.innerHTML = ` Genres & moods› ${esc(data.title || title)} ${esc(data.title || title)}${data.tracks.length} tracks${data.author ? ' · ' + esc(data.author) : ''} ${icon('play')}Play playlist ${trackRows(data.tracks, 'playlist')}`; markNowPlaying(); } catch (e) { if (token !== state.token) return; main.innerHTML = `Couldn't load playlist — ${esc(e.message)}`; } } /* ── artist / album ─────────────────────────────────────────────────────── */ async function renderArtistView(id, name) { const token = ++state.token; main.innerHTML = `${esc(name || '…')}Loading artist…${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; state.viewLists.top = a.top_tracks; const listeners = a.monthly_listeners ? `${Number(a.monthly_listeners).toLocaleString()} monthly listeners` : ''; main.innerHTML = ` Artist ${esc(a.name)} ${[a.subscribers ? `${a.subscribers} subscribers` : '', listeners].filter(Boolean).join(' · ')} ${a.description ? `${esc(a.description)}` : ''} ${icon('spark')} Play artist radio ${a.top_tracks.length ? `${icon('play')} Play top songs` : ''} ${icon('play')}Top songs ${trackRows(a.top_tracks, 'top')} ${a.similar.length ? ` ${icon('artist')}Fans also like ${a.similar.map(s => artistCard({ id: s.id, name: s.name, thumbnail: s.thumbnail })).join('')}` : ''} ${a.albums.length ? ` ${icon('queue')}Albums & singles ${a.albums.map(al => ` ${esc(al.title)}${esc(al.type)}${al.year ? ' · ' + al.year : ''} open → `).join('')}` : ''} `; markNowPlaying(); } catch (e) { if (token !== state.token) return; main.innerHTML = `Couldn't load artist — ${esc(e.message)}`; } } async function renderAlbum(id) { const token = ++state.token; main.innerHTML = `Dashboard› Album${skeletons(8)}`; try { const al = await api(`/api/album?id=${encodeURIComponent(id)}`); if (token !== state.token) return; state.viewLists.album = al.tracks; main.innerHTML = ` Dashboard› Album ${esc(al.title)}${[al.year, `${al.tracks.length} tracks`].filter(Boolean).join(' · ')} ${icon('play')}Play album ${trackRows(al.tracks, 'album')}`; markNowPlaying(); } catch (e) { if (token !== state.token) return; main.innerHTML = `Couldn't load album — ${esc(e.message)}`; } } /* ── recently played ────────────────────────────────────────────────────── */ function renderRecentView() { state.token++; const recent = LS.get('recent', []); state.viewLists.recent = recent; main.innerHTML = ` Recently played${recent.length} tracks from this browser ${recent.length ? `${icon('play')}Play all` : ''} ${recent.length ? trackRows(recent, 'recent') : 'Nothing played yet.'}`; markNowPlaying(); } /* ── playback ───────────────────────────────────────────────────────────── */ function setQueue(list, startIndex, label, station = null) { state.queue = list.slice(); state.queueSource = list; state.index = startIndex; state.queueLabel = label || null; state.station = station || null; refreshQueue(); } function playTrackAt(i, { addToHistory = true } = {}) { if (i < 0 || i >= state.queue.length) return; const t = state.queue[i]; if (addToHistory && state.current) state.history = state.history || []; 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.station; document.title = `${t.title} · ${artistName(t)} — Cadence`; updateMediaSession(t); setLoading(true); audio.src = `/api/stream/${t.video_id}`; audio.play().catch(err => { setLoading(false); if (!err || err.name === 'AbortError') return; // give the stream diagnosis a moment to speak first — it knows the real reason setTimeout(() => { if (!blockingReported) toast('Playback blocked — press play to start', true); }, 1500); }); remember(t); bufferAhead(); markNowPlaying(); refreshQueue(); paintNowPlaying(); paintFeed(); 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, 20)); } function togglePlay() { if (!state.current) { go('/search'); 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) { 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)); } async function growQueue(force = false) { 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=25`); 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.suggestionsAdded += fresh.length; state.station = 'Radio · ' + (state.current ? artistName(state.current) : 'station'); refreshQueue(); paintFeed(); paintStats(); $('#radioBadge').hidden = false; if (force) toast(`+${fresh.length} suggestions queued`); 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 <= 4) growQueue(); } /* buffer-ahead telemetry */ async function bufferAhead() { const nxt = state.queue[state.index + 1]; state.buffered = { id: nxt ? nxt.video_id : null, ready: false, bytes: 0 }; paintStats(); if (!nxt) return; try { await fetch(`/api/prefetch/${nxt.video_id}`, { method: 'POST' }); } catch {} for (let attempt = 0; attempt < 24; attempt++) { await new Promise(r => setTimeout(r, attempt < 6 ? 250 : 800)); if (state.buffered.id !== nxt.video_id) return; // track changed meanwhile try { const d = await api(`/api/ready/${nxt.video_id}`); if (d.cached) { state.buffered.ready = true; state.buffered.bytes = d.bytes; paintStats(); return; } } catch {} } } 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}`, `Radio · ${artistName(t)}`); state.suggestionsAdded += rest.length; playTrackAt(0); toast(`▶ ${rest.length} songs like ${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}`, `Artist radio · ${data.station_name || name}`); state.suggestionsAdded += tracks.length; playTrackAt(0); toast(`▶ ${data.station_name || name} radio — ${tracks.length} tracks`); } catch (e) { toast('Radio failed: ' + e.message, true); } } /* ── queue rail ─────────────────────────────────────────────────────────── */ function refreshQueue(label) { const list = $('#queueList'); if (!list) return; 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.queueLabel || '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 plumbing ─────────────────────────────────────────────────────── */ function setLoading(on) { $('#spinner').hidden = !on; } let errStreak = 0; audio.addEventListener('playing', () => { setLoading(false); setPlayIcon(true); markNowPlaying(); updateHeroState(); errStreak = 0; blockingReported = false; if (state.queue.length - state.index <= 4) growQueue(); }); audio.addEventListener('pause', () => { setPlayIcon(false); updateHeroState(); }); 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) + '%'; } if ($('#heroFacts')) paintStats(); }); audio.addEventListener('ended', () => next(true)); audio.addEventListener('error', async () => { setLoading(false); if (!state.current) return; const id = state.current.video_id; const info = await diagnoseStream(id); // YouTube refusing this server's IP won't be fixed by skipping tracks if (info.kind === 'youtube_blocked') { reportBlocked(info); audio.pause(); return; } if (info.kind === 'offline') { toast('Backend unreachable — is the Flask app still running?', true, 9000); audio.pause(); 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.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); let seeking = false; const seek = $('#seek'); ['input', 'change'].forEach(ev => seek.addEventListener(ev, () => { 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 === 'input') seeking = true; if (ev === 'change') { if (d) audio.currentTime = (seek.value / 1000) * d; seeking = false; } })); seek.addEventListener('pointerup', () => { seeking = false; }); $('#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(); $('#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]); }); $('#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.station = null; $('#radioBadge').hidden = true; refreshQueue(); paintFeed(); paintStats(); toast('Queue cleared'); }); $('#queueToggle').addEventListener('click', e => { const narrow = window.matchMedia('(max-width:900px)').matches; if (narrow) $('#rail').classList.toggle('open'); else $('.body').classList.toggle('rail-hidden'); e.currentTarget.classList.toggle('active'); }); /* ── click delegation in the main area ──────────────────────────────────── */ main.addEventListener('click', e => { const navTo = e.target.closest('[data-nav-to]'); if (navTo) { go('/' + navTo.dataset.navTo); return; } const artistEl = e.target.closest('[data-artist]'); if (artistEl) { e.preventDefault(); // hash href keeps working when this is a synthetic click too const id = artistEl.dataset.artistId || ''; const nm = artistEl.dataset.artistName || ''; if (id || nm) go(`/artist/${id ? encodeURIComponent(id) : ''}?name=${encodeURIComponent(nm)}`); return; } const albumEl = e.target.closest('[data-album]'); if (albumEl) { e.preventDefault(); go(`/album/${encodeURIComponent(albumEl.dataset.album)}`); return; } const genreEl = e.target.closest('[data-genre]'); if (genreEl) { go(`/genre/${encodeURIComponent(genreEl.dataset.genre)}?title=${encodeURIComponent(genreEl.dataset.title)}`); return; } const plEl = e.target.closest('[data-playlist]'); if (plEl) { go(`/playlist/${encodeURIComponent(plEl.dataset.playlist)}?title=${encodeURIComponent(plEl.dataset.title)}`); return; } const chip = e.target.closest('[data-search]'); if (chip) { $('#q').value = chip.dataset.search; runSearch(chip.dataset.search); 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(); paintFeed(); paintStats(); 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 === 'similar-now' && state.current) { playSimilar(state.current); return; } if (a === 'open-settings') { if (window.__SRC__ && window.__SRC__.openSettings) window.__SRC__.openSettings(); else toast('Settings are only available in the standalone file', true); return; } if (a === 'playpause') { togglePlay(); return; } if (a === 'play-list') { const list = state.viewLists[act.dataset.list] || []; if (list.length) { setQueue(list, 0, LABELS[act.dataset.list] || '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', 'Radiohead', 'Khruangbin']; const pick = picks[Math.floor(Math.random() * picks.length)]; $('#q').value = pick; runSearch(pick); return; } } const row = e.target.closest('.trow'); if (row) { const list = state.viewLists[row.dataset.list] || []; const i = Number(row.dataset.i); if (list.length) { if (state.queueSource !== list) setQueue(list, i, LABELS[row.dataset.list] || 'Playing list'); playTrackAt(i); } } }); /* suggestion feed rows live outside `main` re-renders when re-painted: delegate on document */ document.addEventListener('click', e => { const row = e.target.closest('.feed-row[data-qi]'); if (row) playTrackAt(Number(row.dataset.qi)); }); /* nav + hash routing */ document.querySelectorAll('.nav-item').forEach(b => b.addEventListener('click', () => go('/' + b.dataset.nav))); window.addEventListener('hashchange', renderRoute); /* ── search box ─────────────────────────────────────────────────────────── */ let suggestTimer = null; const qInput = $('#q'); function runSearch(q) { $('#q').value = q; hideSuggest(); go(`/search/${encodeURIComponent(q)}?q=${encodeURIComponent(q)}`); if (currentRoute() === `/search/${encodeURIComponent(q)}?q=${encodeURIComponent(q)}`) renderRoute(); } 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 && state.suggestItems.length; if (e.key === 'ArrowDown' && open) { e.preventDefault(); state.suggestActive = Math.min((state.suggestActive ?? -1) + 1, state.suggestItems.length - 1); paintSuggest(); } else if (e.key === 'ArrowUp' && open) { e.preventDefault(); state.suggestActive = Math.max((state.suggestActive ?? -1) - 1, -1); paintSuggest(); } else if (e.key === 'Enter') { const v = (open && (state.suggestActive ?? -1) >= 0) ? state.suggestItems[state.suggestActive] : qInput.value; if (v && v.trim()) runSearch(v.trim()); } 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'); const items = state.suggestItems || []; if (!items.length) { hideSuggest(); return; } box.innerHTML = items.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 + media session ───────────────────────────────────────────── */ 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; } }); 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; try { 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' }], }); } catch {} } /* ── engine telemetry poll ──────────────────────────────────────────────── */ let backendWarned = false; async function pollStats() { if (document.hidden) return; try { const s = await api('/api/stats'); backendWarned = false; $('#enCache').textContent = fmtBytes(s.cached_bytes); $('#enTracks').textContent = s.cached_tracks; $('#enDownloads').textContent = s.downloads + (s.download_failures ? ` (${s.download_failures} err)` : ''); $('#enAvg').textContent = s.avg_download_seconds ? `${s.avg_download_seconds}s` : '–'; $('#enInflight').textContent = s.downloads_inflight; $('#enUptime').textContent = fmtDur(s.uptime_seconds); $('#enBar').style.width = Math.min(100, (s.cached_bytes / s.cache_limit_bytes) * 100).toFixed(1) + '%'; } catch { if (!backendWarned) { backendWarned = true; toast('Backend not reachable — start it with ./run.sh (search, streaming and suggestions need it)', true, 9000); } } } pollStats(); setInterval(pollStats, 5000); /* ── boot ───────────────────────────────────────────────────────────────── */ $('#brandLink').addEventListener('click', e => { e.preventDefault(); go('/overview'); }); if (!location.hash) location.replace('#/overview'); renderRoute(); console.log('%c🎧 Cadence dashboard ready', 'color:#a78bfa;font-weight:bold'); })();
${window.__SRC__ && window.__SRC__.mode === 'demo' ? 'This file is running in demo mode, so it can only search its built-in library. Connect a backend to search all of YouTube Music.' : 'Try a different spelling, or one of these searches:'}
${esc(e.message)}
${esc(a.description)}