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 || '';