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..c34d1cc 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -84,6 +84,20 @@ +
+

Quick Stations

+

+ Pick which SomaFM channels show in the chip row on the New Tab Page. + — selected +

+ +
+ +
+
+

Playback

diff --git a/src/options/options.js b/src/options/options.js index e2cda8b..a28d5cf 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -13,6 +13,10 @@ 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 +38,10 @@ 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'), }; init().catch(err => { @@ -48,6 +56,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(); @@ -101,6 +112,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 +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() { const [history, favs, all] = await Promise.all([ getHistory(),