Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e75acad1b8 | |||
| dbbcf55b2b | |||
| 131569963a | |||
| ada3c4e18a | |||
| 60331b8350 | |||
| 529409eed9 |
+57
-1
@@ -7,7 +7,7 @@ Format: [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) — versi
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Planned — Future polish
|
||||
### Planned — Long-term
|
||||
- `.m3u` parser alongside `.pls` to widen future-adapter compatibility
|
||||
- Station artwork lazy-load + fallback to family helmet
|
||||
- Better error UI for failed streams ("Stream unavailable, try another")
|
||||
@@ -16,6 +16,62 @@ Format: [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) — versi
|
||||
|
||||
---
|
||||
|
||||
## [0.5.3] — 2026-06-10 — User-customizable Quick Stations + history export ("app is done")
|
||||
|
||||
Per David: *"this app is kinda done"* — meaning the product surface is feature-complete. After this version, polish remains but no headline features are missing.
|
||||
|
||||
### Added — User-customizable Quick Stations
|
||||
|
||||
Pure-data architecture: the NewTab Quick Stations chip row is now driven by `tuner.quickStations` in `chrome.storage.local` rather than a hardcoded `QUICK_IDS` array. **Default 8 (tidy 2-row layout, matches v0.4.0 look).** User can pick any subset of all 46 SomaFM channels via a new Options page card.
|
||||
|
||||
**Where:** new "Quick Stations" card on the Options page (sits between Appearance and Playback). Layout: helper text + selected-count badge + scrollable checkbox list + Reset-to-defaults button.
|
||||
|
||||
**Each list row** shows: checkbox + station name + genre pill (accent green, uppercase) + the SomaFM channel description (the same string available as a chip-hover tooltip on the NewTab — now properly visible at the time of picking).
|
||||
|
||||
**Live sync:** ticking a checkbox writes to `chrome.storage.local`. The NewTab `chrome.storage.onChanged` listener picks up the change and re-renders the Quick Stations chip row instantly without a reload. Cross-surface consistency is the same pattern as theme + station selection.
|
||||
|
||||
**Robust fresh-install fallback:** if Options is opened BEFORE the NewTab has ever populated the station catalogue cache, `initQuickStationsUI` fetches SomaFM channels directly via `listAllStations()` then caches the result back to `tuner.stationsCache`. Future Options opens are then instant.
|
||||
|
||||
**Empty-pick case:** if the user unticks everything, the Quick Stations row on the NewTab shows *"No Quick Stations picked — set some in ⚙ Settings."* instead of a silently empty section.
|
||||
|
||||
### Added — History export (JSON + CSV)
|
||||
|
||||
Two new buttons in the Local Data card: **Export history (JSON)** and **Export history (CSV)**.
|
||||
|
||||
- **JSON**: full pretty-printed array of `tuner.history` entries. Best for programmatic re-import or backup.
|
||||
- **CSV**: RFC 4180 quoted (every cell quoted, doubled quotes for embedded quotes). Header: `artist,title,station,station_id,when_iso`. Opens cleanly in Excel / Numbers / Google Sheets.
|
||||
|
||||
Filename: `rangerhq-tuner-history-YYYY-MM-DD.{ext}`.
|
||||
|
||||
Empty-history case shows an error toast ("No history to export yet — play some music first"). Success case shows a green toast with the export count and format.
|
||||
|
||||
No new permissions — uses standard `Blob` + `URL.createObjectURL` + a programmatic `<a>` click. The entire export runs client-side in the Options page; nothing is sent off-device.
|
||||
|
||||
### New / changed files
|
||||
|
||||
- `src/lib/quick-stations.js` (NEW, ~55 lines) — `DEFAULT_QUICK_IDS` constant + `getQuickStations / setQuickStations / resetQuickStations` helpers. Same defensive `storage()` accessor pattern as `lib/history.js` and `lib/theme.js`.
|
||||
- `src/newtab/newtab.js` — removed hardcoded `QUICK_IDS` array, replaced with module-level `quickIds = []` populated during `init()` from `getQuickStations()` and re-loaded when `chrome.storage.onChanged` fires on `tuner.quickStations`. `renderQuick()` shows the empty-pick fallback message.
|
||||
- `src/options/options.html` — new "Quick Stations" card + two export buttons in the Local Data card.
|
||||
- `src/options/options.css` — ~90 lines for the picker (scrollable list, row layout, accent-coloured count badge, hover background, brand scrollbar).
|
||||
- `src/options/options.js` — `initQuickStationsUI` builds the checkbox list, `onQuickStationToggle` persists picks, `exportHistoryAs` generates the download Blob. Plus the storage.onChanged listener was made async to support cross-surface picker sync.
|
||||
|
||||
Total: 5 files, +372 / -25 lines (1 new file).
|
||||
|
||||
### Not changed
|
||||
|
||||
- No new permissions
|
||||
- No new host_permissions
|
||||
- No new external libraries / SDKs
|
||||
- No data migration required
|
||||
- Existing user state (current station / volume / history / favourites / theme) survives intact
|
||||
- Defaults match v0.4.0's curated Quick Stations exactly — users who never open Options get the same look they had before
|
||||
|
||||
### Workflow note
|
||||
|
||||
This release was bundled into v0.5.0's eventual Web Store upload — when v0.5.0 clears re-review, we build a v0.5.3 ZIP directly from main and upload that instead. Users at v0.4.0 jump straight to v0.5.3 (skipping v0.5.0, v0.5.1, v0.5.2) and get all the polish in a single update notification.
|
||||
|
||||
---
|
||||
|
||||
## [0.5.2] — 2026-06-10 — Quick Stations fix: replace retired BAGeL with PopTron
|
||||
|
||||
Hotfix for v0.5.1's Quick Stations row. David flagged: *"the stations still look a bit off"* — turned out only 13 chips were rendering instead of the intended 14. Investigation showed **BAGeL Radio has been retired from SomaFM's current `channels.json` catalogue** — the render loop's "drop missing slugs silently" behaviour (working as designed) made the chip just disappear.
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# Privacy Policy — RangerHQ Tuner
|
||||
|
||||
**Effective date:** 2026-06-09
|
||||
**Effective date:** 2026-06-10
|
||||
**Maintainer:** David Keane — <david@davidtkeane.com>
|
||||
**Extension homepage:** https://davidtkeane.com/rangerhq-tuner
|
||||
**Source code:** https://git.davidtkeane.com/ranger/rangerhq-tuner
|
||||
@@ -49,6 +49,8 @@ RangerHQ Tuner uses Chrome's `chrome.storage.local` API to persist a small amoun
|
||||
| `tuner.history` | A capped log of artist + title pairs heard while playing | The History tab on the New Tab Page |
|
||||
| `tuner.favourites` | Track entries you have starred | The Favourites tab |
|
||||
| `tuner.historyCap` | Your chosen history-cap value (default 500) | Configurable on the Options page |
|
||||
| `tuner.theme` | Your theme preference (`auto` / `dark` / `light`) | Configurable on the Options page; default `auto` follows OS |
|
||||
| `tuner.quickStations` | The SomaFM channel short IDs you chose to surface in the New Tab Page Quick Stations row | Configurable on the Options page; default is a curated 8 |
|
||||
|
||||
The history and favourites entries contain: artist name, track title, station display name, station ID, and a local timestamp. **They are stored in your browser only.** They are never sent to the extension author or any third party.
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "RangerHQ Tuner",
|
||||
"version": "0.5.2",
|
||||
"version": "0.5.3",
|
||||
"description": "Lightweight SomaFM radio player. Logs what plays. One-click search to Spotify, YouTube, Apple Music, Bandcamp. No telemetry.",
|
||||
"author": "David Keane",
|
||||
"homepage_url": "https://davidtkeane.com/rangerhq-tuner",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+20
-24
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="opt-actions">
|
||||
<button id="opt-export-json" class="opt-btn">Export history (JSON)</button>
|
||||
<button id="opt-export-csv" class="opt-btn">Export history (CSV)</button>
|
||||
</div>
|
||||
|
||||
<div class="opt-actions">
|
||||
<button id="opt-clear-history" class="opt-btn">Clear history</button>
|
||||
<button id="opt-clear-favs" class="opt-btn">Clear favourites</button>
|
||||
@@ -84,6 +89,20 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="opt-card">
|
||||
<h2>Quick Stations</h2>
|
||||
<p class="opt-note">
|
||||
Pick which SomaFM channels show in the chip row on the New Tab Page.
|
||||
<span id="opt-qs-count" class="opt-qs-count">— selected</span>
|
||||
</p>
|
||||
<ul id="opt-qs-list" class="opt-qs-list" role="list">
|
||||
<li class="opt-qs-loading">Loading channel list…</li>
|
||||
</ul>
|
||||
<div class="opt-actions">
|
||||
<button id="opt-qs-reset" class="opt-btn" type="button">Reset to defaults (8)</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="opt-card">
|
||||
<h2>Playback</h2>
|
||||
<div class="opt-row opt-row--inline">
|
||||
|
||||
+178
-1
@@ -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 || '';
|
||||
|
||||
Reference in New Issue
Block a user