60331b8350
Two fixes in one commit, both responding to David's testing of
v0.5.3-prep:
1. Quick Stations 'Loading...' stuck
The picker showed 'Loading channel list…' indefinitely when the
user opened Options BEFORE ever opening the New Tab Page (so the
tuner.stationsCache hadn't been populated yet). Fix:
initQuickStationsUI now falls back to calling listAllStations()
directly if the cache is empty, then writes the result back to
cache so future opens are instant. New error message if the network
fetch fails: 'Could not load SomaFM channel list — check your
network connection and reopen Settings.'
2. History export (JSON + CSV)
Added two buttons in the Local data card 'Export history (JSON)'
and 'Export history (CSV)'. JSON dumps the full tuner.history array
pretty-printed; CSV uses RFC 4180 quoting (every cell quoted, doubled
quotes for embedded quotes) with header artist,title,station,
station_id,when_iso. Filename: rangerhq-tuner-history-YYYY-MM-DD.{ext}.
Empty-history case shows an error toast. Toast confirms count + format
on success.
Per David: 'this app is kinda done' after this, then upload to Web
Store once v0.5.0 clears.
362 lines
12 KiB
JavaScript
362 lines
12 KiB
JavaScript
// 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);
|
|
}
|