diff --git a/src/options/options.html b/src/options/options.html index c34d1cc..3e40901 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -53,6 +53,11 @@ +
+ + +
+
diff --git a/src/options/options.js b/src/options/options.js index a28d5cf..2354b78 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -9,6 +9,7 @@ import { 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, @@ -42,6 +43,9 @@ const els = { 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 => { @@ -76,6 +80,9 @@ async function init() { 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(); @@ -151,7 +158,24 @@ async function initThemeUI() { * tuner.quickStations. NewTab's storage.onChanged listener re-renders * the chip row instantly without a reload. */ async function initQuickStationsUI() { - const cache = (await chrome.storage.local.get('tuner.stationsCache'))['tuner.stationsCache'] || []; + // 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 || '')); @@ -161,7 +185,7 @@ async function initQuickStationsUI() { if (!channels.length) { const li = document.createElement('li'); li.className = 'opt-qs-loading'; - li.textContent = 'Channel list not cached yet — open the New Tab Page once to populate it.'; + li.textContent = 'Could not load SomaFM channel list — check your network connection and reopen Settings.'; els.qsList.appendChild(li); return; } @@ -288,6 +312,46 @@ function formatBytes(n) { 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 || '';