b82f14ee7b
The 'pick a station to begin' state was too subtle on first install
(David's own 30-second panic moment when he uninstalled the dev build
and reinstalled from the Web Store, 2026-06-09 evening).
Two layered cues, both pure CSS driven by a body.is-first-run class:
1. Subtle accent-green glow pulses around:
- popup: the station list section
- newtab: the Quick Stations chip row
(rgba alpha 0.18-0.25, 2.4s ease-in-out infinite — visible but not noisy)
2. Bouncing ↓ arrow appended to the 'Pick a station to begin' text in
both surfaces (after-pseudo with translateY animation).
The is-first-run class is toggled in popup.js + newtab.js via a new
reflectFirstRunHint() function called from:
- init() once stations + currentStation are resolved
- onPickStation() the moment a user picks
- the chrome.storage.onChanged listener when another surface picks
(so the hint disappears on both surfaces simultaneously)
Existing users with stored currentStationId never see either cue —
the class only attaches when currentStation is null.
Working on branch v0.4.0-prep so the live main (= what shipped to
Web Store v0.3.0) is unchanged. Merge to main when ready to bump the
manifest version + readme.txt + CHANGELOG.md for v0.4.0 release.
526 lines
17 KiB
JavaScript
526 lines
17 KiB
JavaScript
// RangerHQ Tuner — New Tab Page controller.
|
|
// Replaces Chrome's default new tab with the player as a focal landing.
|
|
// Same underlying mechanics as the popup (sources/index, messages to SW,
|
|
// chrome.storage state) — but laid out for a full viewport with a
|
|
// quick-station chip row and a searchable browse list.
|
|
|
|
import { TARGETS, TYPES } from '../lib/messages.js';
|
|
import { listAllStations, getSource } from '../sources/index.js';
|
|
import {
|
|
getHistory, getFavourites,
|
|
toggleFavourite, isFavourited,
|
|
clearHistory, clearFavourites,
|
|
searchUrls, formatRelativeTime,
|
|
entrySignature,
|
|
} from '../lib/history.js';
|
|
|
|
const els = {
|
|
// Clock
|
|
hm: document.getElementById('nt-hm'),
|
|
secs: document.getElementById('nt-secs'),
|
|
date: document.getElementById('nt-date'),
|
|
// Now playing
|
|
station: document.getElementById('nt-station'),
|
|
track: document.getElementById('nt-track'),
|
|
state: document.getElementById('nt-state'),
|
|
// Controls
|
|
play: document.getElementById('nt-play'),
|
|
volume: document.getElementById('nt-volume'),
|
|
// Stations
|
|
quickList: document.getElementById('nt-quick-list'),
|
|
search: document.getElementById('nt-search'),
|
|
list: document.getElementById('nt-station-list'),
|
|
// Tabs + panes
|
|
tabBar: document.querySelector('.nt-tab-bar'),
|
|
paneStations: document.getElementById('nt-pane-stations'),
|
|
paneHistory: document.getElementById('nt-pane-history'),
|
|
paneFavs: document.getElementById('nt-pane-favourites'),
|
|
historyList: document.getElementById('nt-history-list'),
|
|
favsList: document.getElementById('nt-favs-list'),
|
|
historyCount: document.getElementById('nt-history-count'),
|
|
favsCount: document.getElementById('nt-favs-count'),
|
|
historyClear: document.getElementById('nt-history-clear'),
|
|
favsClear: document.getElementById('nt-favs-clear'),
|
|
openOptions: document.getElementById('nt-open-options'),
|
|
};
|
|
|
|
const STORAGE_KEYS = {
|
|
stations: 'tuner.stationsCache',
|
|
cachedAt: 'tuner.stationsCachedAt',
|
|
currentId: 'tuner.currentStationId',
|
|
volume: 'tuner.volume',
|
|
isPlaying: 'tuner.isPlaying',
|
|
};
|
|
|
|
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.
|
|
const QUICK_IDS = [
|
|
'groovesalad',
|
|
'dronezone',
|
|
'indiepop',
|
|
'secretagent',
|
|
'spacestation',
|
|
'lush',
|
|
'deepspaceone',
|
|
'fluid',
|
|
];
|
|
|
|
let stations = [];
|
|
let currentStation = null;
|
|
let playing = false;
|
|
let lastQuery = '';
|
|
let clockTimer = null;
|
|
|
|
init().catch(err => {
|
|
console.error('Newtab init failed:', err);
|
|
setState('error');
|
|
els.track.textContent = `Init failed: ${err.message}`;
|
|
});
|
|
|
|
async function init() {
|
|
// Clock first — visible even before stations load.
|
|
tickClock();
|
|
clockTimer = setInterval(tickClock, 1000); // 1 Hz — live seconds
|
|
|
|
const stored = await chrome.storage.local.get(Object.values(STORAGE_KEYS));
|
|
if (typeof stored[STORAGE_KEYS.volume] === 'number') {
|
|
els.volume.value = String(Math.round(stored[STORAGE_KEYS.volume] * 100));
|
|
}
|
|
playing = !!stored[STORAGE_KEYS.isPlaying];
|
|
setState(playing ? 'playing' : 'idle');
|
|
reflectPlayButton();
|
|
|
|
const cached = stored[STORAGE_KEYS.stations];
|
|
const cachedAt = stored[STORAGE_KEYS.cachedAt];
|
|
const fresh = cached && cachedAt && (Date.now() - cachedAt < CATALOGUE_TTL_MS);
|
|
|
|
if (fresh) {
|
|
stations = cached;
|
|
} else {
|
|
stations = await listAllStations();
|
|
await chrome.storage.local.set({
|
|
[STORAGE_KEYS.stations]: stations,
|
|
[STORAGE_KEYS.cachedAt]: Date.now(),
|
|
});
|
|
}
|
|
|
|
const currentId = stored[STORAGE_KEYS.currentId];
|
|
currentStation = stations.find(s => s.id === currentId) || null;
|
|
reflectFirstRunHint();
|
|
renderNow();
|
|
renderQuick();
|
|
renderList();
|
|
|
|
els.play.addEventListener('click', onPlayToggle);
|
|
els.volume.addEventListener('input', onVolume);
|
|
els.search.addEventListener('input', () => {
|
|
lastQuery = els.search.value.trim().toLowerCase();
|
|
renderList();
|
|
});
|
|
|
|
// Tabs
|
|
els.tabBar.addEventListener('click', (e) => {
|
|
const tab = e.target.closest('.nt-tab');
|
|
if (!tab) return;
|
|
if (tab.id === 'nt-open-options') {
|
|
e.preventDefault();
|
|
chrome.runtime.openOptionsPage();
|
|
return;
|
|
}
|
|
selectTab(tab.dataset.tab);
|
|
});
|
|
els.historyClear.addEventListener('click', async () => {
|
|
if (!confirm('Clear all track history? Favourites will be preserved.')) return;
|
|
await clearHistory();
|
|
await renderHistory();
|
|
});
|
|
els.favsClear.addEventListener('click', async () => {
|
|
if (!confirm('Clear all favourites?')) return;
|
|
await clearFavourites();
|
|
await renderFavourites();
|
|
await renderHistory(); // re-render history so star indicators update
|
|
});
|
|
|
|
// First render of track panes
|
|
await renderHistory();
|
|
await renderFavourites();
|
|
|
|
// Deep-link via URL hash — popup's "History" / "Favourites" buttons
|
|
// open us with #history or #favourites. Respect that on load AND on
|
|
// hash change (e.g. user changes it manually).
|
|
applyHashTab();
|
|
window.addEventListener('hashchange', applyHashTab);
|
|
|
|
chrome.runtime.onMessage.addListener((msg) => {
|
|
if (msg.target !== TARGETS.POPUP) return; // SW broadcasts to "popup" as the UI bucket
|
|
if (msg.type === TYPES.STATE_CHANGED) {
|
|
setState(msg.state);
|
|
playing = (msg.state === 'playing');
|
|
reflectPlayButton();
|
|
chrome.storage.local.set({ [STORAGE_KEYS.isPlaying]: playing });
|
|
} else if (msg.type === TYPES.ERROR) {
|
|
setState('error');
|
|
els.track.textContent = `Stream error (code ${msg.code ?? '?'})`;
|
|
} else if (msg.type === TYPES.METADATA_UPDATED) {
|
|
if (msg.nowPlaying) els.track.textContent = formatNowPlaying(msg.nowPlaying);
|
|
} else if (msg.type === TYPES.TRACK_LOGGED) {
|
|
renderHistory();
|
|
} else if (msg.type === TYPES.STORAGE_WIPED) {
|
|
// Catastrophic reset — re-read everything from scratch.
|
|
renderHistory();
|
|
renderFavourites();
|
|
renderQuick();
|
|
renderList();
|
|
}
|
|
});
|
|
|
|
// Cross-surface sync — if the popup (or another newtab) changes the
|
|
// current station, re-render here live without a manual reload.
|
|
chrome.storage.onChanged.addListener(async (changes, area) => {
|
|
if (area !== 'local') return;
|
|
|
|
if (changes[STORAGE_KEYS.currentId] && stations.length) {
|
|
const newId = changes[STORAGE_KEYS.currentId].newValue;
|
|
currentStation = stations.find(s => s.id === newId) || null;
|
|
els.play.disabled = !currentStation;
|
|
reflectFirstRunHint();
|
|
renderNow();
|
|
renderQuick();
|
|
renderList();
|
|
}
|
|
|
|
if (changes[STORAGE_KEYS.isPlaying]) {
|
|
playing = !!changes[STORAGE_KEYS.isPlaying].newValue;
|
|
reflectPlayButton();
|
|
}
|
|
|
|
if (changes['tuner.history']) renderHistory();
|
|
if (changes['tuner.favourites']) {
|
|
renderFavourites();
|
|
renderHistory();
|
|
}
|
|
});
|
|
|
|
els.play.disabled = !currentStation;
|
|
}
|
|
|
|
/* ---------- Clock ---------- */
|
|
|
|
function tickClock() {
|
|
const now = new Date();
|
|
const hh = String(now.getHours()).padStart(2, '0');
|
|
const mm = String(now.getMinutes()).padStart(2, '0');
|
|
const ss = String(now.getSeconds()).padStart(2, '0');
|
|
els.hm.textContent = `${hh}:${mm}`;
|
|
els.secs.textContent = ss;
|
|
// Date only changes once a day; rendering the same string per tick is
|
|
// free (no DOM diff cost) so we don't bother to short-circuit it.
|
|
els.date.textContent = now.toLocaleDateString(undefined, {
|
|
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
|
});
|
|
}
|
|
|
|
/* ---------- Rendering ---------- */
|
|
|
|
function renderNow() {
|
|
if (currentStation) {
|
|
els.station.textContent = currentStation.name;
|
|
els.track.textContent = playing ? 'Now playing — fetching metadata…' : 'Ready to play';
|
|
} else {
|
|
els.station.textContent = 'Pick a station to begin';
|
|
els.track.textContent = '—';
|
|
}
|
|
}
|
|
|
|
function renderQuick() {
|
|
els.quickList.innerHTML = '';
|
|
if (!stations.length) {
|
|
const empty = document.createElement('span');
|
|
empty.className = 'nt-quick-empty';
|
|
empty.textContent = 'Loading…';
|
|
els.quickList.appendChild(empty);
|
|
return;
|
|
}
|
|
for (const shortId of QUICK_IDS) {
|
|
const fullId = `somafm:${shortId}`;
|
|
const s = stations.find(st => st.id === fullId);
|
|
if (!s) continue;
|
|
const chip = document.createElement('button');
|
|
chip.className = 'nt-quick-chip';
|
|
if (currentStation && currentStation.id === s.id) chip.classList.add('is-active');
|
|
chip.type = 'button';
|
|
chip.textContent = s.name;
|
|
chip.title = s.description || s.genre || s.name;
|
|
chip.addEventListener('click', () => onPickStation(s));
|
|
els.quickList.appendChild(chip);
|
|
}
|
|
}
|
|
|
|
function renderList() {
|
|
if (!stations.length) {
|
|
els.list.innerHTML = '<li class="nt-quick-empty" style="padding:14px;">Loading stations…</li>';
|
|
return;
|
|
}
|
|
const q = lastQuery;
|
|
const filtered = q
|
|
? stations.filter(s =>
|
|
s.name.toLowerCase().includes(q) ||
|
|
s.genre.toLowerCase().includes(q) ||
|
|
s.description.toLowerCase().includes(q))
|
|
: stations;
|
|
|
|
els.list.innerHTML = '';
|
|
if (!filtered.length) {
|
|
const li = document.createElement('li');
|
|
li.className = 'nt-quick-empty';
|
|
li.style.padding = '14px';
|
|
li.textContent = 'No matches.';
|
|
els.list.appendChild(li);
|
|
return;
|
|
}
|
|
|
|
for (const s of filtered) {
|
|
const li = document.createElement('li');
|
|
li.className = 'nt-station-row';
|
|
if (currentStation && currentStation.id === s.id) li.classList.add('is-active');
|
|
li.setAttribute('role', 'option');
|
|
|
|
const img = document.createElement('img');
|
|
img.className = 'nt-station-art';
|
|
img.alt = '';
|
|
img.src = s.artworkUrl || '';
|
|
img.onerror = () => { img.style.visibility = 'hidden'; };
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'nt-station-meta';
|
|
|
|
const name = document.createElement('div');
|
|
name.className = 'nt-station-name';
|
|
name.textContent = s.name;
|
|
|
|
const genre = document.createElement('div');
|
|
genre.className = 'nt-station-genre';
|
|
genre.textContent = s.genre || s.description.slice(0, 80);
|
|
|
|
meta.appendChild(name);
|
|
meta.appendChild(genre);
|
|
li.appendChild(img);
|
|
li.appendChild(meta);
|
|
li.addEventListener('click', () => onPickStation(s));
|
|
|
|
els.list.appendChild(li);
|
|
}
|
|
}
|
|
|
|
/* ---------- Actions ---------- */
|
|
|
|
async function onPickStation(station) {
|
|
currentStation = station;
|
|
els.play.disabled = false;
|
|
reflectFirstRunHint();
|
|
await chrome.storage.local.set({ [STORAGE_KEYS.currentId]: station.id });
|
|
renderNow();
|
|
renderQuick();
|
|
renderList();
|
|
await playCurrent();
|
|
}
|
|
|
|
async function onPlayToggle() {
|
|
if (!currentStation) return;
|
|
if (playing) {
|
|
await sendToSW({ type: TYPES.PAUSE });
|
|
} else {
|
|
await playCurrent();
|
|
}
|
|
}
|
|
|
|
async function playCurrent() {
|
|
if (!currentStation) return;
|
|
setState('buffering');
|
|
try {
|
|
const source = getSource(currentStation.sourceId);
|
|
if (!source) throw new Error(`Unknown source ${currentStation.sourceId}`);
|
|
const streamUrl = await source.resolveStreamUrl(currentStation);
|
|
await sendToSW({ type: TYPES.SET_VOLUME, volume: Number(els.volume.value) / 100 });
|
|
const stationLite = {
|
|
id: currentStation.id,
|
|
sourceId: currentStation.sourceId,
|
|
name: currentStation.name,
|
|
};
|
|
const resp = await sendToSW({ type: TYPES.PLAY, streamUrl, station: stationLite });
|
|
if (!resp?.ok) throw new Error(resp?.error || 'PLAY failed');
|
|
// Offscreen will broadcast METADATA_UPDATED within ~1s.
|
|
} catch (err) {
|
|
console.error(err);
|
|
setState('error');
|
|
els.track.textContent = `Couldn't play: ${err.message}`;
|
|
}
|
|
}
|
|
|
|
async function onVolume() {
|
|
const v = Number(els.volume.value) / 100;
|
|
await chrome.storage.local.set({ [STORAGE_KEYS.volume]: v });
|
|
await sendToSW({ type: TYPES.SET_VOLUME, volume: v });
|
|
}
|
|
|
|
/* ---------- Helpers ---------- */
|
|
|
|
// v0.4.0 first-run UX hint — body class drives the pulse + arrow CSS in
|
|
// newtab.css. Active when no station has been picked yet; cleared the
|
|
// moment one is selected (here or via cross-surface storage sync).
|
|
function reflectFirstRunHint() {
|
|
document.body.classList.toggle('is-first-run', !currentStation);
|
|
}
|
|
|
|
function reflectPlayButton() {
|
|
els.play.setAttribute('aria-pressed', playing ? 'true' : 'false');
|
|
els.play.textContent = playing ? '❚❚ Pause' : '▶ Play';
|
|
}
|
|
|
|
function setState(state) {
|
|
els.state.dataset.state = state;
|
|
els.state.textContent = state;
|
|
}
|
|
|
|
function formatNowPlaying(np) {
|
|
const left = np.artist ? `${np.artist} — ` : '';
|
|
return `${left}${np.title}`;
|
|
}
|
|
|
|
function sendToSW(msg) {
|
|
return chrome.runtime.sendMessage({ target: TARGETS.SW, ...msg });
|
|
}
|
|
|
|
/* ---------- Tabs ---------- */
|
|
|
|
function selectTab(name) {
|
|
const map = {
|
|
stations: els.paneStations,
|
|
history: els.paneHistory,
|
|
favourites: els.paneFavs,
|
|
};
|
|
for (const [k, pane] of Object.entries(map)) {
|
|
const active = (k === name);
|
|
pane.hidden = !active;
|
|
const tabBtn = els.tabBar.querySelector(`[data-tab="${k}"]`);
|
|
if (tabBtn) {
|
|
tabBtn.classList.toggle('is-active', active);
|
|
tabBtn.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyHashTab() {
|
|
const hash = (location.hash || '').replace(/^#/, '').toLowerCase();
|
|
if (hash === 'history' || hash === 'favourites' || hash === 'stations') {
|
|
selectTab(hash);
|
|
}
|
|
}
|
|
|
|
/* ---------- History + Favourites rendering ---------- */
|
|
|
|
async function renderHistory() {
|
|
const entries = await getHistory();
|
|
els.historyCount.textContent = `${entries.length} track${entries.length === 1 ? '' : 's'}`;
|
|
if (!entries.length) {
|
|
els.historyList.innerHTML = '<li class="nt-track-empty">No tracks logged yet. Play a station for a minute and they\'ll appear here.</li>';
|
|
return;
|
|
}
|
|
// Newest first
|
|
const favs = await getFavourites();
|
|
const favSigs = new Set(favs.map(entrySignature));
|
|
els.historyList.innerHTML = '';
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
els.historyList.appendChild(renderTrackRow(entries[i], favSigs));
|
|
}
|
|
}
|
|
|
|
async function renderFavourites() {
|
|
const entries = await getFavourites();
|
|
els.favsCount.textContent = `${entries.length} favourite${entries.length === 1 ? '' : 's'}`;
|
|
if (!entries.length) {
|
|
els.favsList.innerHTML = '<li class="nt-track-empty">No favourites yet. Tap the star next to a history track to add it.</li>';
|
|
return;
|
|
}
|
|
const favSigs = new Set(entries.map(entrySignature));
|
|
els.favsList.innerHTML = '';
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
els.favsList.appendChild(renderTrackRow(entries[i], favSigs));
|
|
}
|
|
}
|
|
|
|
function renderTrackRow(entry, favSigs) {
|
|
const li = document.createElement('li');
|
|
li.className = 'nt-track-row';
|
|
|
|
// Main column — title (big), artist (accent), meta line
|
|
const main = document.createElement('div');
|
|
main.className = 'nt-track-main';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'nt-track-title';
|
|
title.textContent = entry.title;
|
|
title.title = entry.title;
|
|
|
|
const artist = document.createElement('div');
|
|
artist.className = 'nt-track-artist';
|
|
artist.textContent = entry.artist;
|
|
artist.title = entry.artist;
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'nt-track-meta';
|
|
const station = entry.station || '—';
|
|
meta.textContent = `${station} · ${formatRelativeTime(entry.at)}`;
|
|
|
|
main.append(title, artist, meta);
|
|
|
|
// Right column — star + (future) overflow menu
|
|
const controls = document.createElement('div');
|
|
controls.className = 'nt-track-controls';
|
|
|
|
const fav = document.createElement('button');
|
|
fav.type = 'button';
|
|
fav.className = 'nt-fav-btn';
|
|
const isFav = favSigs.has(entrySignature(entry));
|
|
fav.setAttribute('aria-pressed', isFav ? 'true' : 'false');
|
|
fav.setAttribute('aria-label', isFav ? 'Remove from favourites' : 'Add to favourites');
|
|
fav.textContent = isFav ? '★' : '☆';
|
|
fav.addEventListener('click', async () => {
|
|
const nowFav = await toggleFavourite(entry);
|
|
fav.setAttribute('aria-pressed', nowFav ? 'true' : 'false');
|
|
fav.textContent = nowFav ? '★' : '☆';
|
|
fav.setAttribute('aria-label', nowFav ? 'Remove from favourites' : 'Add to favourites');
|
|
// Re-render favs tab so it reflects the change.
|
|
renderFavourites();
|
|
});
|
|
|
|
controls.appendChild(fav);
|
|
|
|
// Search-link row — spans full width below
|
|
const search = document.createElement('div');
|
|
search.className = 'nt-search-row';
|
|
const urls = searchUrls(entry.artist, entry.title);
|
|
|
|
const linkOf = (svc, label) => {
|
|
const a = document.createElement('a');
|
|
a.className = `nt-search-link nt-search-link--${svc}`;
|
|
a.href = urls[svc];
|
|
a.target = '_blank';
|
|
a.rel = 'noopener noreferrer';
|
|
a.textContent = label;
|
|
a.title = `Search ${label} for "${entry.artist} — ${entry.title}"`;
|
|
return a;
|
|
};
|
|
search.append(
|
|
linkOf('spotify', 'Spotify'),
|
|
linkOf('youtube', 'YouTube'),
|
|
linkOf('apple', 'Apple'),
|
|
linkOf('bandcamp', 'Bandcamp'),
|
|
);
|
|
|
|
li.append(main, controls, search);
|
|
return li;
|
|
}
|