feat(v0.5.3-prep): user-customizable Quick Stations + descriptions
David flagged 2026-06-10: '14 stations wraps 3 lines, original idea was 2 lines tidy, but now thought of user selecting themselves' + 'maybe have a description of each station' + 'be great to keep the code clean.' Architecture: pure data, not code. Adds tuner.quickStations as a new chrome.storage.local key. Default 8 (tidy 2-row layout per the v0.4.0 picks). User can pick any subset of the 46 SomaFM channels via a new Options page card. NEW FILE src/lib/quick-stations.js — DEFAULT_QUICK_IDS + storage helpers (getQuickStations, setQuickStations, resetQuickStations) with the same defensive 'fall back if chrome.storage missing' pattern as history.js + theme.js. CHANGED src/newtab/newtab.js — removed hardcoded QUICK_IDS array, replaced with module-level 'let quickIds = []' populated during init from getQuickStations() + re-loaded when chrome.storage.onChanged fires on tuner.quickStations. renderQuick() now uses module-level quickIds and shows 'No Quick Stations picked — set some in Settings' empty state if the array is empty. src/options/options.html — new 'Quick Stations' card between Appearance and Playback. Contains: helper text + selected-count badge + scrollable <ul> + Reset-to-defaults button. src/options/options.css — ~90 lines for the new picker: scrollable list (max-height 360px), checkbox-name-description row layout, accent-coloured count badge, subtle hover background, brand-styled scrollbar. src/options/options.js — initQuickStationsUI() reads cached channel list from tuner.stationsCache, sorts alphabetically, builds one row per channel with checkbox + name + genre pill + description. Toggle handler writes the current pick set to chrome.storage.local. Reset button confirms then calls resetQuickStations() + re-checks boxes to match defaults. Cross-surface sync via storage.onChanged re-syncs check state if changes happen elsewhere. 5 files (1 new), ~250 lines added. No new permissions, no new dependencies.
This commit is contained in:
@@ -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,
|
entrySignature,
|
||||||
} from '../lib/history.js';
|
} from '../lib/history.js';
|
||||||
import { THEME_KEY, THEME_DEFAULT, initTheme, applyTheme } from '../lib/theme.js';
|
import { THEME_KEY, THEME_DEFAULT, initTheme, applyTheme } from '../lib/theme.js';
|
||||||
|
import { QUICK_STATIONS_KEY, getQuickStations } from '../lib/quick-stations.js';
|
||||||
|
|
||||||
const els = {
|
const els = {
|
||||||
// Clock
|
// Clock
|
||||||
@@ -55,29 +56,11 @@ const STORAGE_KEYS = {
|
|||||||
|
|
||||||
const CATALOGUE_TTL_MS = 6 * 60 * 60 * 1000;
|
const CATALOGUE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
// Quick-pick stations. These are the SomaFM ids most people start with.
|
// Quick-pick stations come from chrome.storage.local under tuner.quickStations,
|
||||||
// If the catalogue is missing any (rare), they're just dropped silently.
|
// 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.
|
||||||
// Layout: 14 stations across roughly chill / ambient / indie / lounge /
|
// See src/lib/quick-stations.js for the persistence helpers + defaults.
|
||||||
// space / electronic / americana / 80s / alt-rock / celtic — broader
|
let quickIds = [];
|
||||||
// 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)
|
|
||||||
];
|
|
||||||
|
|
||||||
let stations = [];
|
let stations = [];
|
||||||
let currentStation = null;
|
let currentStation = null;
|
||||||
@@ -126,6 +109,7 @@ async function init() {
|
|||||||
currentStation = stations.find(s => s.id === currentId) || null;
|
currentStation = stations.find(s => s.id === currentId) || null;
|
||||||
reflectFirstRunHint();
|
reflectFirstRunHint();
|
||||||
renderNow();
|
renderNow();
|
||||||
|
quickIds = await getQuickStations();
|
||||||
renderQuick();
|
renderQuick();
|
||||||
renderList();
|
renderList();
|
||||||
|
|
||||||
@@ -201,6 +185,11 @@ async function init() {
|
|||||||
applyTheme(changes[THEME_KEY].newValue || THEME_DEFAULT);
|
applyTheme(changes[THEME_KEY].newValue || THEME_DEFAULT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (changes[QUICK_STATIONS_KEY]) {
|
||||||
|
quickIds = await getQuickStations();
|
||||||
|
renderQuick();
|
||||||
|
}
|
||||||
|
|
||||||
if (changes[STORAGE_KEYS.currentId] && stations.length) {
|
if (changes[STORAGE_KEYS.currentId] && stations.length) {
|
||||||
const newId = changes[STORAGE_KEYS.currentId].newValue;
|
const newId = changes[STORAGE_KEYS.currentId].newValue;
|
||||||
currentStation = stations.find(s => s.id === newId) || null;
|
currentStation = stations.find(s => s.id === newId) || null;
|
||||||
@@ -263,7 +252,14 @@ function renderQuick() {
|
|||||||
els.quickList.appendChild(empty);
|
els.quickList.appendChild(empty);
|
||||||
return;
|
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 fullId = `somafm:${shortId}`;
|
||||||
const s = stations.find(st => st.id === fullId);
|
const s = stations.find(st => st.id === fullId);
|
||||||
if (!s) continue;
|
if (!s) continue;
|
||||||
|
|||||||
@@ -367,3 +367,100 @@ html, body {
|
|||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
color: var(--cream);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,6 +84,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="opt-card">
|
||||||
<h2>Playback</h2>
|
<h2>Playback</h2>
|
||||||
<div class="opt-row opt-row--inline">
|
<div class="opt-row opt-row--inline">
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import {
|
|||||||
THEME_KEY, THEME_DEFAULT, VALID_THEMES,
|
THEME_KEY, THEME_DEFAULT, VALID_THEMES,
|
||||||
getTheme, setTheme, applyTheme,
|
getTheme, setTheme, applyTheme,
|
||||||
} from '../lib/theme.js';
|
} from '../lib/theme.js';
|
||||||
|
import {
|
||||||
|
QUICK_STATIONS_KEY, DEFAULT_QUICK_IDS,
|
||||||
|
getQuickStations, setQuickStations, resetQuickStations,
|
||||||
|
} from '../lib/quick-stations.js';
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
stations: 'tuner.stationsCache',
|
stations: 'tuner.stationsCache',
|
||||||
@@ -34,6 +38,10 @@ const els = {
|
|||||||
volumeDisplay: document.getElementById('opt-volume-display'),
|
volumeDisplay: document.getElementById('opt-volume-display'),
|
||||||
lastStation: document.getElementById('opt-last-station'),
|
lastStation: document.getElementById('opt-last-station'),
|
||||||
toast: document.getElementById('opt-toast'),
|
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'),
|
||||||
};
|
};
|
||||||
|
|
||||||
init().catch(err => {
|
init().catch(err => {
|
||||||
@@ -48,6 +56,9 @@ async function init() {
|
|||||||
// cross-surface changes (popup/newtab pick a theme → Options reflects).
|
// cross-surface changes (popup/newtab pick a theme → Options reflects).
|
||||||
await initThemeUI();
|
await initThemeUI();
|
||||||
|
|
||||||
|
// Quick Stations — render checkbox list of all SomaFM channels.
|
||||||
|
await initQuickStationsUI();
|
||||||
|
|
||||||
await refreshStats();
|
await refreshStats();
|
||||||
await refreshPlayback();
|
await refreshPlayback();
|
||||||
|
|
||||||
@@ -101,6 +112,15 @@ async function init() {
|
|||||||
const radio = document.querySelector(`input[name="opt-theme"][value="${v}"]`);
|
const radio = document.querySelector(`input[name="opt-theme"][value="${v}"]`);
|
||||||
if (radio) radio.checked = true;
|
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.'))) {
|
if (Object.keys(changes).some(k => k.startsWith('tuner.'))) {
|
||||||
refreshStats();
|
refreshStats();
|
||||||
refreshPlayback();
|
refreshPlayback();
|
||||||
@@ -125,6 +145,99 @@ 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() {
|
||||||
|
const cache = (await chrome.storage.local.get('tuner.stationsCache'))['tuner.stationsCache'] || [];
|
||||||
|
// 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 = 'Channel list not cached yet — open the New Tab Page once to populate it.';
|
||||||
|
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() {
|
async function refreshStats() {
|
||||||
const [history, favs, all] = await Promise.all([
|
const [history, favs, all] = await Promise.all([
|
||||||
getHistory(),
|
getHistory(),
|
||||||
|
|||||||
Reference in New Issue
Block a user