// RangerHQ Tuner — Options page controller. // Manages the user's local data: history cap, clear actions, stats display. // Broadcasts STORAGE_WIPED after destructive actions so the popup/newtab // rerender immediately. import { TARGETS, TYPES } from '../lib/messages.js'; import { getHistory, getFavourites, getHistoryCap, setHistoryCap, clearHistory, clearFavourites, clearAll, } from '../lib/history.js'; import { listAllStations } from '../sources/index.js'; import { THEME_KEY, THEME_DEFAULT, VALID_THEMES, getTheme, setTheme, applyTheme, } from '../lib/theme.js'; import { QUICK_STATIONS_KEY, DEFAULT_QUICK_IDS, getQuickStations, setQuickStations, resetQuickStations, } from '../lib/quick-stations.js'; const STORAGE_KEYS = { stations: 'tuner.stationsCache', currentId: 'tuner.currentStationId', volume: 'tuner.volume', }; const els = { version: document.getElementById('opt-version'), statHistory: document.getElementById('opt-stat-history'), statFavs: document.getElementById('opt-stat-favs'), statCache: document.getElementById('opt-stat-cache'), statBytes: document.getElementById('opt-stat-bytes'), capSlider: document.getElementById('opt-cap'), capValue: document.getElementById('opt-cap-value'), clearHistory: document.getElementById('opt-clear-history'), clearFavs: document.getElementById('opt-clear-favs'), clearAll: document.getElementById('opt-clear-all'), volumeDisplay: document.getElementById('opt-volume-display'), lastStation: document.getElementById('opt-last-station'), toast: document.getElementById('opt-toast'), // Quick Stations picker (v0.5.3) qsCount: document.getElementById('opt-qs-count'), qsList: document.getElementById('opt-qs-list'), qsReset: document.getElementById('opt-qs-reset'), // Export history (v0.5.3) exportJson: document.getElementById('opt-export-json'), exportCsv: document.getElementById('opt-export-csv'), }; init().catch(err => { console.error('Options init failed:', err); toast(`Init failed: ${err.message}`, 'error'); }); async function init() { els.version.textContent = `v${chrome.runtime.getManifest().version}`; // Theme — apply stored preference + wire the radio group + listen for // cross-surface changes (popup/newtab pick a theme → Options reflects). await initThemeUI(); // Quick Stations — render checkbox list of all SomaFM channels. await initQuickStationsUI(); await refreshStats(); await refreshPlayback(); const cap = await getHistoryCap(); els.capSlider.value = String(cap); els.capValue.textContent = String(cap); els.capSlider.addEventListener('input', () => { els.capValue.textContent = els.capSlider.value; }); els.capSlider.addEventListener('change', async () => { const newCap = await setHistoryCap(Number(els.capSlider.value)); els.capValue.textContent = String(newCap); toast(`History cap set to ${newCap}`); await refreshStats(); }); els.exportJson.addEventListener('click', () => exportHistoryAs('json')); els.exportCsv.addEventListener('click', () => exportHistoryAs('csv')); els.clearHistory.addEventListener('click', async () => { if (!confirm('Clear all track history? Favourites will be preserved.')) return; await clearHistory(); await refreshStats(); notifyUis(); toast('History cleared'); }); els.clearFavs.addEventListener('click', async () => { if (!confirm('Clear all favourites?')) return; await clearFavourites(); await refreshStats(); notifyUis(); toast('Favourites cleared'); }); els.clearAll.addEventListener('click', async () => { if (!confirm('Wipe EVERYTHING — history, favourites, station cache, volume, last station? This cannot be undone.')) return; await clearAll(); await refreshStats(); await refreshPlayback(); els.capSlider.value = '500'; els.capValue.textContent = '500'; notifyUis(); toast('All local data wiped', 'danger'); }); // Refresh stats live if storage changes from another surface chrome.storage.onChanged.addListener((changes, area) => { if (area !== 'local') return; if (changes[THEME_KEY]) { const v = changes[THEME_KEY].newValue || THEME_DEFAULT; applyTheme(v); const radio = document.querySelector(`input[name="opt-theme"][value="${v}"]`); if (radio) radio.checked = true; } if (changes[QUICK_STATIONS_KEY]) { // Re-check the boxes to match the new picks (in case another surface // wiped storage or reset to defaults). const newPicks = new Set(await getQuickStations()); for (const cb of document.querySelectorAll('#opt-qs-list input[type=checkbox]')) { cb.checked = newPicks.has(cb.value); } updateQuickStationsCount(newPicks.size); } if (Object.keys(changes).some(k => k.startsWith('tuner.'))) { refreshStats(); refreshPlayback(); } }); } /** Apply the stored theme, check the matching radio, wire change handlers. */ async function initThemeUI() { const current = await getTheme(); applyTheme(current); const checked = document.querySelector(`input[name="opt-theme"][value="${current}"]`); if (checked) checked.checked = true; for (const r of document.querySelectorAll('input[name="opt-theme"]')) { r.addEventListener('change', async (e) => { const v = e.target.value; if (!VALID_THEMES.includes(v)) return; await setTheme(v); applyTheme(v); toast(`Theme: ${v}`); }); } } /** Render the Quick Stations picker — checkbox per SomaFM channel from * the cached catalogue, with description below each name. Toggling a * checkbox writes the current picks to chrome.storage.local under * tuner.quickStations. NewTab's storage.onChanged listener re-renders * the chip row instantly without a reload. */ async function initQuickStationsUI() { // Try the NewTab cache first (fast path). let cache = (await chrome.storage.local.get('tuner.stationsCache'))['tuner.stationsCache'] || []; // Fresh install fallback: if Options is opened BEFORE NewTab has ever // populated the cache, fetch SomaFM channels directly + cache them. if (!cache.length) { try { cache = await listAllStations(); // Cache it back so future loads are instant. await chrome.storage.local.set({ 'tuner.stationsCache': cache, 'tuner.stationsCachedAt': Date.now(), }); } catch (err) { console.warn('Quick Stations: failed to fetch channel list', err); } } // Stations come in as { id: "somafm:groovesalad", name, description, genre, ... } // Sort alphabetically by name for predictable picker order. const channels = [...cache].sort((a, b) => (a.name || '').localeCompare(b.name || '')); const picks = new Set(await getQuickStations()); els.qsList.innerHTML = ''; if (!channels.length) { const li = document.createElement('li'); li.className = 'opt-qs-loading'; li.textContent = 'Could not load SomaFM channel list — check your network connection and reopen Settings.'; els.qsList.appendChild(li); return; } for (const ch of channels) { const shortId = (ch.id || '').split(':')[1]; if (!shortId) continue; const li = document.createElement('li'); li.className = 'opt-qs-row'; const label = document.createElement('label'); label.className = 'opt-qs-label'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.value = shortId; cb.checked = picks.has(shortId); cb.addEventListener('change', onQuickStationToggle); const main = document.createElement('div'); main.className = 'opt-qs-main'; const name = document.createElement('div'); name.className = 'opt-qs-name'; name.textContent = ch.name || shortId; if (ch.genre) { const g = document.createElement('div'); g.className = 'opt-qs-genre'; g.textContent = ch.genre; main.appendChild(name); main.appendChild(g); } else { main.appendChild(name); } if (ch.description) { const desc = document.createElement('div'); desc.className = 'opt-qs-desc'; desc.textContent = ch.description; main.appendChild(desc); } label.appendChild(cb); label.appendChild(main); li.appendChild(label); els.qsList.appendChild(li); } updateQuickStationsCount(picks.size); els.qsReset.addEventListener('click', async () => { if (!confirm('Reset Quick Stations to the default 8?')) return; await resetQuickStations(); // Re-check the boxes to match defaults const defaultSet = new Set(DEFAULT_QUICK_IDS); for (const cb of document.querySelectorAll('#opt-qs-list input[type=checkbox]')) { cb.checked = defaultSet.has(cb.value); } updateQuickStationsCount(defaultSet.size); toast('Quick Stations reset to defaults'); }); } async function onQuickStationToggle() { const all = [...document.querySelectorAll('#opt-qs-list input[type=checkbox]')]; const picked = all.filter(c => c.checked).map(c => c.value); await setQuickStations(picked); updateQuickStationsCount(picked.length); } function updateQuickStationsCount(n) { els.qsCount.textContent = `${n} selected`; } async function refreshStats() { const [history, favs, all] = await Promise.all([ getHistory(), getFavourites(), chrome.storage.local.get(null), ]); const cacheArr = Array.isArray(all[STORAGE_KEYS.stations]) ? all[STORAGE_KEYS.stations] : []; // Estimate "tuner.*" bytes — JSON stringification is close enough for // a user-facing display, no need for the exact storage-engine cost. let bytes = 0; for (const [k, v] of Object.entries(all)) { if (!k.startsWith('tuner.')) continue; try { bytes += k.length + JSON.stringify(v).length; } catch { /* nope */ } } els.statHistory.textContent = `${history.length} tracks`; els.statFavs.textContent = `${favs.length} tracks`; els.statCache.textContent = `${cacheArr.length} stations`; els.statBytes.textContent = formatBytes(bytes); } async function refreshPlayback() { const stored = await chrome.storage.local.get([ STORAGE_KEYS.volume, STORAGE_KEYS.currentId, STORAGE_KEYS.stations, ]); const vol = stored[STORAGE_KEYS.volume]; els.volumeDisplay.textContent = typeof vol === 'number' ? `${Math.round(vol * 100)}%` : '—'; const id = stored[STORAGE_KEYS.currentId]; const cache = Array.isArray(stored[STORAGE_KEYS.stations]) ? stored[STORAGE_KEYS.stations] : []; const cur = cache.find(s => s.id === id); els.lastStation.textContent = cur?.name || (id || '—'); } function notifyUis() { chrome.runtime.sendMessage({ target: TARGETS.POPUP, type: TYPES.STORAGE_WIPED }) .catch(() => {}); // no listeners is fine } function formatBytes(n) { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(2)} MB`; } /** Download tuner.history as a JSON or CSV file. (v0.5.3) */ async function exportHistoryAs(format) { const history = await getHistory(); if (!history.length) { toast('No history to export yet — play some music first', 'error'); return; } let blob, ext, mime; if (format === 'json') { blob = new Blob([JSON.stringify(history, null, 2)], { type: 'application/json' }); ext = 'json'; } else { // CSV — RFC 4180 quoting (doubled quotes, every cell quoted for safety). const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`; const header = 'artist,title,station,station_id,when_iso\n'; const rows = history.map(e => [ q(e.artist), q(e.title), q(e.station), q(e.stationId), q(e.at ? new Date(e.at).toISOString() : ''), ].join(',')).join('\n'); blob = new Blob([header + rows + '\n'], { type: 'text/csv;charset=utf-8' }); ext = 'csv'; } const ts = new Date().toISOString().slice(0, 10); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `rangerhq-tuner-history-${ts}.${ext}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); toast(`Exported ${history.length} track${history.length === 1 ? '' : 's'} as ${ext.toUpperCase()}`); } function toast(message, tone) { els.toast.textContent = message; els.toast.dataset.tone = tone || ''; els.toast.hidden = false; clearTimeout(toast._t); toast._t = setTimeout(() => { els.toast.hidden = true; }, 2400); }