diff --git a/src/lib/quick-stations.js b/src/lib/quick-stations.js
new file mode 100644
index 0000000..27eef16
--- /dev/null
+++ b/src/lib/quick-stations.js
@@ -0,0 +1,58 @@
+// RangerHQ Tuner — Quick Stations preference helpers (v0.5.3).
+//
+// The user picks which SomaFM channels appear in the Quick Stations chip
+// row on the New Tab Page. Stored in chrome.storage.local under
+// `tuner.quickStations` as an array of SomaFM short ids
+// (e.g. ['groovesalad', 'defcon', ...]). If the key is missing, the
+// DEFAULT_QUICK_IDS below are used — a tidy 8 that wraps to 2 rows on
+// most viewports.
+//
+// Cross-surface sync: NewTab's `chrome.storage.onChanged` listener picks
+// up changes so toggling a checkbox in Options re-renders the chip row
+// instantly without a reload.
+
+export const QUICK_STATIONS_KEY = 'tuner.quickStations';
+
+// 8 curated defaults — the same set Tuner v0.4.0 shipped with. Chosen
+// for genre breadth (chill / ambient / indie / lounge / space / vocal /
+// ambient / electronic) and to fit a tidy 2-row chip layout.
+export const DEFAULT_QUICK_IDS = Object.freeze([
+ 'groovesalad',
+ 'dronezone',
+ 'indiepop',
+ 'secretagent',
+ 'spacestation',
+ 'lush',
+ 'deepspaceone',
+ 'fluid',
+]);
+
+function storage() {
+ if (typeof chrome === 'undefined') return null;
+ if (!chrome.storage || !chrome.storage.local) return null;
+ return chrome.storage.local;
+}
+
+/** Read the user's chosen quick-station list, falling back to defaults. */
+export async function getQuickStations() {
+ const s = storage();
+ if (!s) return [...DEFAULT_QUICK_IDS];
+ const o = await s.get(QUICK_STATIONS_KEY);
+ const v = o[QUICK_STATIONS_KEY];
+ return Array.isArray(v) ? v.filter(x => typeof x === 'string') : [...DEFAULT_QUICK_IDS];
+}
+
+/** Persist the user's chosen quick-station list. Empty array = no quick chips. */
+export async function setQuickStations(ids) {
+ const s = storage();
+ if (!s) return;
+ const arr = Array.isArray(ids) ? ids.filter(x => typeof x === 'string') : [];
+ await s.set({ [QUICK_STATIONS_KEY]: arr });
+}
+
+/** Wipe the preference so the default-8 is used again. */
+export async function resetQuickStations() {
+ const s = storage();
+ if (!s) return;
+ await s.remove(QUICK_STATIONS_KEY);
+}
diff --git a/src/newtab/newtab.js b/src/newtab/newtab.js
index 37904de..7b81252 100644
--- a/src/newtab/newtab.js
+++ b/src/newtab/newtab.js
@@ -14,6 +14,7 @@ import {
entrySignature,
} from '../lib/history.js';
import { THEME_KEY, THEME_DEFAULT, initTheme, applyTheme } from '../lib/theme.js';
+import { QUICK_STATIONS_KEY, getQuickStations } from '../lib/quick-stations.js';
const els = {
// Clock
@@ -55,29 +56,11 @@ const STORAGE_KEYS = {
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.
-//
-// Layout: 14 stations across roughly chill / ambient / indie / lounge /
-// space / electronic / americana / 80s / alt-rock / celtic — broader
-// genre coverage than the v0.4.0 list, plus DEF CON Radio (the on-brand
-// cybersecurity pick) and ThistleRadio (the Dublin-built Irish nod).
-const QUICK_IDS = [
- 'groovesalad', // chill / electronic
- 'dronezone', // ambient
- 'indiepop', // indie pop rocks
- 'secretagent', // lounge / spy
- 'spacestation', // ambient / space
- 'lush', // vocal / chill
- 'deepspaceone', // ambient
- 'fluid', // electronic
- 'defcon', // electronic / hacker culture (DEF CON Radio)
- 'beatblender', // electronic / breakbeat
- 'bootliquor', // americana / outlaw country
- 'u80s', // 80s indie / synthwave (Underground 80s)
- 'poptron', // alternative / alt-rock (PopTron) — replaces BAGeL Radio (retired by SomaFM)
- 'thistle', // celtic / folk (ThistleRadio)
-];
+// Quick-pick stations come from chrome.storage.local under tuner.quickStations,
+// with DEFAULT_QUICK_IDS as fallback (8 curated for tidy 2-row layout).
+// User can pick their own set in the Options page's "Quick Stations" card.
+// See src/lib/quick-stations.js for the persistence helpers + defaults.
+let quickIds = [];
let stations = [];
let currentStation = null;
@@ -126,6 +109,7 @@ async function init() {
currentStation = stations.find(s => s.id === currentId) || null;
reflectFirstRunHint();
renderNow();
+ quickIds = await getQuickStations();
renderQuick();
renderList();
@@ -201,6 +185,11 @@ async function init() {
applyTheme(changes[THEME_KEY].newValue || THEME_DEFAULT);
}
+ if (changes[QUICK_STATIONS_KEY]) {
+ quickIds = await getQuickStations();
+ renderQuick();
+ }
+
if (changes[STORAGE_KEYS.currentId] && stations.length) {
const newId = changes[STORAGE_KEYS.currentId].newValue;
currentStation = stations.find(s => s.id === newId) || null;
@@ -263,7 +252,14 @@ function renderQuick() {
els.quickList.appendChild(empty);
return;
}
- for (const shortId of QUICK_IDS) {
+ if (!quickIds.length) {
+ const empty = document.createElement('span');
+ empty.className = 'nt-quick-empty';
+ empty.textContent = 'No Quick Stations picked — set some in ⚙ Settings.';
+ els.quickList.appendChild(empty);
+ return;
+ }
+ for (const shortId of quickIds) {
const fullId = `somafm:${shortId}`;
const s = stations.find(st => st.id === fullId);
if (!s) continue;
diff --git a/src/options/options.css b/src/options/options.css
index 6b356c6..662a2a0 100644
--- a/src/options/options.css
+++ b/src/options/options.css
@@ -367,3 +367,100 @@ html, body {
background: var(--danger);
color: var(--cream);
}
+
+/* ──────────────────────────────────────────────────────────────────────
+ Quick Stations picker (v0.5.3) — scrollable checkbox list of every
+ SomaFM channel. Toggles persist to chrome.storage.local under
+ tuner.quickStations; NewTab re-renders chip row instantly.
+ ────────────────────────────────────────────────────────────────────── */
+
+.opt-qs-count {
+ color: var(--accent);
+ font-weight: 500;
+}
+
+.opt-qs-list {
+ margin: 12px 0 0;
+ padding: 0;
+ list-style: none;
+ max-height: 360px;
+ overflow-y: auto;
+ border: 1px solid var(--bg-row);
+ border-radius: var(--radius);
+ background: var(--bg-row);
+}
+
+.opt-qs-list::-webkit-scrollbar {
+ width: 8px;
+}
+.opt-qs-list::-webkit-scrollbar-thumb {
+ background: var(--bg-row-hi);
+ border-radius: 4px;
+}
+.opt-qs-list::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.opt-qs-loading {
+ padding: 14px;
+ text-align: center;
+ color: var(--fg-muted);
+ font-size: 12px;
+}
+
+.opt-qs-row {
+ border-bottom: 1px solid var(--bg-soft);
+}
+
+.opt-qs-row:last-child {
+ border-bottom: none;
+}
+
+.opt-qs-label {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 10px 14px;
+ margin: 0;
+ cursor: pointer;
+ font-weight: 400;
+}
+
+.opt-qs-label:hover {
+ background: var(--bg-row-hi);
+}
+
+.opt-qs-label input[type="checkbox"] {
+ margin: 3px 0 0 0;
+ accent-color: var(--accent);
+ flex-shrink: 0;
+ width: 14px;
+ height: 14px;
+ cursor: pointer;
+}
+
+.opt-qs-main {
+ flex: 1;
+ min-width: 0;
+}
+
+.opt-qs-name {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--fg);
+}
+
+.opt-qs-genre {
+ font-size: 10px;
+ color: var(--accent);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-top: 2px;
+}
+
+.opt-qs-desc {
+ font-size: 11px;
+ color: var(--fg-muted);
+ margin-top: 3px;
+ line-height: 1.4;
+}
diff --git a/src/options/options.html b/src/options/options.html
index 1a012e7..3e40901 100644
--- a/src/options/options.html
+++ b/src/options/options.html
@@ -53,6 +53,11 @@
+
+
+
+
+
@@ -84,6 +89,20 @@
+
+ Quick Stations
+
+ Pick which SomaFM channels show in the chip row on the New Tab Page.
+ — selected
+
+
+ - Loading channel list…
+
+
+
+
+
+
Playback
diff --git a/src/options/options.js b/src/options/options.js
index e2cda8b..660bf24 100644
--- a/src/options/options.js
+++ b/src/options/options.js
@@ -9,10 +9,15 @@ 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,
} 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',
@@ -34,6 +39,13 @@ const els = {
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 => {
@@ -48,6 +60,9 @@ async function init() {
// 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();
@@ -65,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();
@@ -93,7 +111,7 @@ async function init() {
});
// Refresh stats live if storage changes from another surface
- chrome.storage.onChanged.addListener((changes, area) => {
+ chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'local') return;
if (changes[THEME_KEY]) {
const v = changes[THEME_KEY].newValue || THEME_DEFAULT;
@@ -101,6 +119,15 @@ async function init() {
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();
@@ -125,6 +152,116 @@ async function initThemeUI() {
}
}
+/** 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(),
@@ -175,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 || '';