// RangerHQ Tuner — New Tab Page controller. // Replaces Chrome's default new tab with the player as a focal landing. // Same underlying mechanics as the popup (sources/index, messages to SW, // chrome.storage state) — but laid out for a full viewport with a // quick-station chip row and a searchable browse list. import { TARGETS, TYPES } from '../lib/messages.js'; import { listAllStations, getSource } from '../sources/index.js'; import { getHistory, getFavourites, toggleFavourite, isFavourited, clearHistory, clearFavourites, searchUrls, formatRelativeTime, entrySignature, } from '../lib/history.js'; import { THEME_KEY, THEME_DEFAULT, initTheme, applyTheme } from '../lib/theme.js'; const els = { // Clock hm: document.getElementById('nt-hm'), secs: document.getElementById('nt-secs'), date: document.getElementById('nt-date'), // Now playing station: document.getElementById('nt-station'), track: document.getElementById('nt-track'), state: document.getElementById('nt-state'), // Controls play: document.getElementById('nt-play'), volume: document.getElementById('nt-volume'), // Stations quickList: document.getElementById('nt-quick-list'), search: document.getElementById('nt-search'), list: document.getElementById('nt-station-list'), // Tabs + panes tabBar: document.querySelector('.nt-tab-bar'), paneStations: document.getElementById('nt-pane-stations'), paneHistory: document.getElementById('nt-pane-history'), paneFavs: document.getElementById('nt-pane-favourites'), historyList: document.getElementById('nt-history-list'), favsList: document.getElementById('nt-favs-list'), historyCount: document.getElementById('nt-history-count'), favsCount: document.getElementById('nt-favs-count'), historyClear: document.getElementById('nt-history-clear'), favsClear: document.getElementById('nt-favs-clear'), openOptions: document.getElementById('nt-open-options'), }; const STORAGE_KEYS = { stations: 'tuner.stationsCache', cachedAt: 'tuner.stationsCachedAt', currentId: 'tuner.currentStationId', volume: 'tuner.volume', isPlaying: 'tuner.isPlaying', }; const CATALOGUE_TTL_MS = 6 * 60 * 60 * 1000; // Quick-pick stations. These are the SomaFM ids most people start with. // If the catalogue is missing any (rare), they're just dropped silently. const QUICK_IDS = [ 'groovesalad', 'dronezone', 'indiepop', 'secretagent', 'spacestation', 'lush', 'deepspaceone', 'fluid', ]; let stations = []; let currentStation = null; let playing = false; let lastQuery = ''; let clockTimer = null; init().catch(err => { console.error('Newtab init failed:', err); setState('error'); els.track.textContent = `Init failed: ${err.message}`; }); async function init() { // Theme first — apply stored preference before anything paints to avoid // a brief dark flash if the user has picked light. await initTheme(); // Clock — visible even before stations load. tickClock(); clockTimer = setInterval(tickClock, 1000); // 1 Hz — live seconds const stored = await chrome.storage.local.get(Object.values(STORAGE_KEYS)); if (typeof stored[STORAGE_KEYS.volume] === 'number') { els.volume.value = String(Math.round(stored[STORAGE_KEYS.volume] * 100)); } playing = !!stored[STORAGE_KEYS.isPlaying]; setState(playing ? 'playing' : 'idle'); reflectPlayButton(); const cached = stored[STORAGE_KEYS.stations]; const cachedAt = stored[STORAGE_KEYS.cachedAt]; const fresh = cached && cachedAt && (Date.now() - cachedAt < CATALOGUE_TTL_MS); if (fresh) { stations = cached; } else { stations = await listAllStations(); await chrome.storage.local.set({ [STORAGE_KEYS.stations]: stations, [STORAGE_KEYS.cachedAt]: Date.now(), }); } const currentId = stored[STORAGE_KEYS.currentId]; currentStation = stations.find(s => s.id === currentId) || null; reflectFirstRunHint(); renderNow(); renderQuick(); renderList(); els.play.addEventListener('click', onPlayToggle); els.volume.addEventListener('input', onVolume); els.search.addEventListener('input', () => { lastQuery = els.search.value.trim().toLowerCase(); renderList(); }); // Tabs els.tabBar.addEventListener('click', (e) => { const tab = e.target.closest('.nt-tab'); if (!tab) return; if (tab.id === 'nt-open-options') { e.preventDefault(); chrome.runtime.openOptionsPage(); return; } selectTab(tab.dataset.tab); }); els.historyClear.addEventListener('click', async () => { if (!confirm('Clear all track history? Favourites will be preserved.')) return; await clearHistory(); await renderHistory(); }); els.favsClear.addEventListener('click', async () => { if (!confirm('Clear all favourites?')) return; await clearFavourites(); await renderFavourites(); await renderHistory(); // re-render history so star indicators update }); // First render of track panes await renderHistory(); await renderFavourites(); // Deep-link via URL hash — popup's "History" / "Favourites" buttons // open us with #history or #favourites. Respect that on load AND on // hash change (e.g. user changes it manually). applyHashTab(); window.addEventListener('hashchange', applyHashTab); chrome.runtime.onMessage.addListener((msg) => { if (msg.target !== TARGETS.POPUP) return; // SW broadcasts to "popup" as the UI bucket if (msg.type === TYPES.STATE_CHANGED) { setState(msg.state); playing = (msg.state === 'playing'); reflectPlayButton(); chrome.storage.local.set({ [STORAGE_KEYS.isPlaying]: playing }); } else if (msg.type === TYPES.ERROR) { setState('error'); els.track.textContent = `Stream error (code ${msg.code ?? '?'})`; } else if (msg.type === TYPES.METADATA_UPDATED) { if (msg.nowPlaying) els.track.textContent = formatNowPlaying(msg.nowPlaying); } else if (msg.type === TYPES.TRACK_LOGGED) { renderHistory(); } else if (msg.type === TYPES.STORAGE_WIPED) { // Catastrophic reset — re-read everything from scratch. renderHistory(); renderFavourites(); renderQuick(); renderList(); } }); // Cross-surface sync — if the popup (or another newtab) changes the // current station, re-render here live without a manual reload. chrome.storage.onChanged.addListener(async (changes, area) => { if (area !== 'local') return; if (changes[THEME_KEY]) { applyTheme(changes[THEME_KEY].newValue || THEME_DEFAULT); } if (changes[STORAGE_KEYS.currentId] && stations.length) { const newId = changes[STORAGE_KEYS.currentId].newValue; currentStation = stations.find(s => s.id === newId) || null; els.play.disabled = !currentStation; reflectFirstRunHint(); renderNow(); renderQuick(); renderList(); } if (changes[STORAGE_KEYS.isPlaying]) { playing = !!changes[STORAGE_KEYS.isPlaying].newValue; reflectPlayButton(); } if (changes['tuner.history']) renderHistory(); if (changes['tuner.favourites']) { renderFavourites(); renderHistory(); } }); els.play.disabled = !currentStation; } /* ---------- Clock ---------- */ function tickClock() { const now = new Date(); const hh = String(now.getHours()).padStart(2, '0'); const mm = String(now.getMinutes()).padStart(2, '0'); const ss = String(now.getSeconds()).padStart(2, '0'); els.hm.textContent = `${hh}:${mm}`; els.secs.textContent = ss; // Date only changes once a day; rendering the same string per tick is // free (no DOM diff cost) so we don't bother to short-circuit it. els.date.textContent = now.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', }); } /* ---------- Rendering ---------- */ function renderNow() { if (currentStation) { els.station.textContent = currentStation.name; els.track.textContent = playing ? 'Now playing — fetching metadata…' : 'Ready to play'; } else { els.station.textContent = 'Pick a station to begin'; els.track.textContent = '—'; } } function renderQuick() { els.quickList.innerHTML = ''; if (!stations.length) { const empty = document.createElement('span'); empty.className = 'nt-quick-empty'; empty.textContent = 'Loading…'; els.quickList.appendChild(empty); return; } for (const shortId of QUICK_IDS) { const fullId = `somafm:${shortId}`; const s = stations.find(st => st.id === fullId); if (!s) continue; const chip = document.createElement('button'); chip.className = 'nt-quick-chip'; if (currentStation && currentStation.id === s.id) chip.classList.add('is-active'); chip.type = 'button'; chip.textContent = s.name; chip.title = s.description || s.genre || s.name; chip.addEventListener('click', () => onPickStation(s)); els.quickList.appendChild(chip); } } function renderList() { if (!stations.length) { els.list.innerHTML = '
  • Loading stations…
  • '; return; } const q = lastQuery; const filtered = q ? stations.filter(s => s.name.toLowerCase().includes(q) || s.genre.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)) : stations; els.list.innerHTML = ''; if (!filtered.length) { const li = document.createElement('li'); li.className = 'nt-quick-empty'; li.style.padding = '14px'; li.textContent = 'No matches.'; els.list.appendChild(li); return; } for (const s of filtered) { const li = document.createElement('li'); li.className = 'nt-station-row'; if (currentStation && currentStation.id === s.id) li.classList.add('is-active'); li.setAttribute('role', 'option'); const img = document.createElement('img'); img.className = 'nt-station-art'; img.alt = ''; img.src = s.artworkUrl || ''; img.onerror = () => { img.style.visibility = 'hidden'; }; const meta = document.createElement('div'); meta.className = 'nt-station-meta'; const name = document.createElement('div'); name.className = 'nt-station-name'; name.textContent = s.name; const genre = document.createElement('div'); genre.className = 'nt-station-genre'; genre.textContent = s.genre || s.description.slice(0, 80); meta.appendChild(name); meta.appendChild(genre); li.appendChild(img); li.appendChild(meta); li.addEventListener('click', () => onPickStation(s)); els.list.appendChild(li); } } /* ---------- Actions ---------- */ async function onPickStation(station) { currentStation = station; els.play.disabled = false; reflectFirstRunHint(); await chrome.storage.local.set({ [STORAGE_KEYS.currentId]: station.id }); renderNow(); renderQuick(); renderList(); await playCurrent(); } async function onPlayToggle() { if (!currentStation) return; if (playing) { await sendToSW({ type: TYPES.PAUSE }); } else { await playCurrent(); } } async function playCurrent() { if (!currentStation) return; setState('buffering'); try { const source = getSource(currentStation.sourceId); if (!source) throw new Error(`Unknown source ${currentStation.sourceId}`); const streamUrl = await source.resolveStreamUrl(currentStation); await sendToSW({ type: TYPES.SET_VOLUME, volume: Number(els.volume.value) / 100 }); const stationLite = { id: currentStation.id, sourceId: currentStation.sourceId, name: currentStation.name, }; const resp = await sendToSW({ type: TYPES.PLAY, streamUrl, station: stationLite }); if (!resp?.ok) throw new Error(resp?.error || 'PLAY failed'); // Offscreen will broadcast METADATA_UPDATED within ~1s. } catch (err) { console.error(err); setState('error'); els.track.textContent = `Couldn't play: ${err.message}`; } } async function onVolume() { const v = Number(els.volume.value) / 100; await chrome.storage.local.set({ [STORAGE_KEYS.volume]: v }); await sendToSW({ type: TYPES.SET_VOLUME, volume: v }); } /* ---------- Helpers ---------- */ // v0.4.0 first-run UX hint — body class drives the pulse + arrow CSS in // newtab.css. Active when no station has been picked yet; cleared the // moment one is selected (here or via cross-surface storage sync). function reflectFirstRunHint() { document.body.classList.toggle('is-first-run', !currentStation); } function reflectPlayButton() { els.play.setAttribute('aria-pressed', playing ? 'true' : 'false'); els.play.textContent = playing ? '❚❚ Pause' : '▶ Play'; } function setState(state) { els.state.dataset.state = state; els.state.textContent = state; } function formatNowPlaying(np) { const left = np.artist ? `${np.artist} — ` : ''; return `${left}${np.title}`; } function sendToSW(msg) { return chrome.runtime.sendMessage({ target: TARGETS.SW, ...msg }); } /* ---------- Tabs ---------- */ function selectTab(name) { const map = { stations: els.paneStations, history: els.paneHistory, favourites: els.paneFavs, }; for (const [k, pane] of Object.entries(map)) { const active = (k === name); pane.hidden = !active; const tabBtn = els.tabBar.querySelector(`[data-tab="${k}"]`); if (tabBtn) { tabBtn.classList.toggle('is-active', active); tabBtn.setAttribute('aria-selected', active ? 'true' : 'false'); } } } function applyHashTab() { const hash = (location.hash || '').replace(/^#/, '').toLowerCase(); if (hash === 'history' || hash === 'favourites' || hash === 'stations') { selectTab(hash); } } /* ---------- History + Favourites rendering ---------- */ async function renderHistory() { const entries = await getHistory(); els.historyCount.textContent = `${entries.length} track${entries.length === 1 ? '' : 's'}`; if (!entries.length) { els.historyList.innerHTML = '
  • No tracks logged yet. Play a station for a minute and they\'ll appear here.
  • '; return; } // Newest first const favs = await getFavourites(); const favSigs = new Set(favs.map(entrySignature)); els.historyList.innerHTML = ''; for (let i = entries.length - 1; i >= 0; i--) { els.historyList.appendChild(renderTrackRow(entries[i], favSigs)); } } async function renderFavourites() { const entries = await getFavourites(); els.favsCount.textContent = `${entries.length} favourite${entries.length === 1 ? '' : 's'}`; if (!entries.length) { els.favsList.innerHTML = '
  • No favourites yet. Tap the star next to a history track to add it.
  • '; return; } const favSigs = new Set(entries.map(entrySignature)); els.favsList.innerHTML = ''; for (let i = entries.length - 1; i >= 0; i--) { els.favsList.appendChild(renderTrackRow(entries[i], favSigs)); } } function renderTrackRow(entry, favSigs) { const li = document.createElement('li'); li.className = 'nt-track-row'; // Main column — title (big), artist (accent), meta line const main = document.createElement('div'); main.className = 'nt-track-main'; const title = document.createElement('div'); title.className = 'nt-track-title'; title.textContent = entry.title; title.title = entry.title; const artist = document.createElement('div'); artist.className = 'nt-track-artist'; artist.textContent = entry.artist; artist.title = entry.artist; const meta = document.createElement('div'); meta.className = 'nt-track-meta'; const station = entry.station || '—'; meta.textContent = `${station} · ${formatRelativeTime(entry.at)}`; main.append(title, artist, meta); // Right column — star + (future) overflow menu const controls = document.createElement('div'); controls.className = 'nt-track-controls'; const fav = document.createElement('button'); fav.type = 'button'; fav.className = 'nt-fav-btn'; const isFav = favSigs.has(entrySignature(entry)); fav.setAttribute('aria-pressed', isFav ? 'true' : 'false'); fav.setAttribute('aria-label', isFav ? 'Remove from favourites' : 'Add to favourites'); fav.textContent = isFav ? '★' : '☆'; fav.addEventListener('click', async () => { const nowFav = await toggleFavourite(entry); fav.setAttribute('aria-pressed', nowFav ? 'true' : 'false'); fav.textContent = nowFav ? '★' : '☆'; fav.setAttribute('aria-label', nowFav ? 'Remove from favourites' : 'Add to favourites'); // Re-render favs tab so it reflects the change. renderFavourites(); }); controls.appendChild(fav); // Search-link row — spans full width below const search = document.createElement('div'); search.className = 'nt-search-row'; const urls = searchUrls(entry.artist, entry.title); const linkOf = (svc, label) => { const a = document.createElement('a'); a.className = `nt-search-link nt-search-link--${svc}`; a.href = urls[svc]; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.textContent = label; a.title = `Search ${label} for "${entry.artist} — ${entry.title}"`; return a; }; search.append( linkOf('spotify', 'Spotify'), linkOf('youtube', 'YouTube'), linkOf('apple', 'Apple'), linkOf('bandcamp', 'Bandcamp'), ); li.append(main, controls, search); return li; }