// 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 = '