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.
283 lines
9.1 KiB
JavaScript
283 lines
9.1 KiB
JavaScript
// RangerHQ Tuner — Popup controller.
|
|
// The popup is a dumb view. State of truth lives in chrome.storage.local
|
|
// and the offscreen <audio> element. We just render and dispatch.
|
|
|
|
import { TARGETS, TYPES } from '../lib/messages.js';
|
|
import { listAllStations, getSource } from '../sources/index.js';
|
|
|
|
const els = {
|
|
statePill: document.getElementById('state-pill'),
|
|
npStation: document.getElementById('np-station'),
|
|
npTrack: document.getElementById('np-track'),
|
|
btnPlay: document.getElementById('btn-play'),
|
|
volume: document.getElementById('volume'),
|
|
search: document.getElementById('search'),
|
|
list: document.getElementById('station-list'),
|
|
navOpenTab: document.getElementById('nav-open-tab'),
|
|
navHistory: document.getElementById('nav-history'),
|
|
navOptions: document.getElementById('nav-options'),
|
|
};
|
|
|
|
const NEWTAB_URL = chrome.runtime.getURL('src/newtab/newtab.html');
|
|
|
|
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; // 6 hours
|
|
|
|
let stations = []; // full list
|
|
let currentStation = null; // last picked
|
|
let playing = false;
|
|
let lastQuery = '';
|
|
|
|
init().catch(err => {
|
|
console.error('Tuner popup init failed:', err);
|
|
setState('error');
|
|
els.npTrack.textContent = `Init failed: ${err.message}`;
|
|
});
|
|
|
|
async function init() {
|
|
// 1. Hydrate UI from cached state so the popup renders fast.
|
|
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();
|
|
|
|
// 2. Load stations — from cache if fresh, else fetch.
|
|
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(),
|
|
});
|
|
}
|
|
|
|
// 3. Restore current station selection.
|
|
const currentId = stored[STORAGE_KEYS.currentId];
|
|
currentStation = stations.find(s => s.id === currentId) || null;
|
|
reflectFirstRunHint();
|
|
renderNowPlaying();
|
|
|
|
// 4. Render station list.
|
|
renderStations();
|
|
|
|
// 5. Wire up controls.
|
|
els.btnPlay.addEventListener('click', onPlayToggle);
|
|
els.volume.addEventListener('input', onVolume);
|
|
els.search.addEventListener('input', () => {
|
|
lastQuery = els.search.value.trim().toLowerCase();
|
|
renderStations();
|
|
});
|
|
|
|
// Nav buttons — popup closes automatically when a tab opens, which is
|
|
// the expected UX. No popup.close() call needed.
|
|
els.navOpenTab.addEventListener('click', () => {
|
|
chrome.tabs.create({ url: NEWTAB_URL });
|
|
});
|
|
els.navHistory.addEventListener('click', () => {
|
|
chrome.tabs.create({ url: `${NEWTAB_URL}#history` });
|
|
});
|
|
els.navOptions.addEventListener('click', () => {
|
|
chrome.runtime.openOptionsPage();
|
|
});
|
|
|
|
// 6. Listen for state changes broadcast from offscreen.
|
|
chrome.runtime.onMessage.addListener((msg) => {
|
|
if (msg.target !== TARGETS.POPUP) return;
|
|
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.npTrack.textContent = `Stream error (code ${msg.code ?? '?'})`;
|
|
} else if (msg.type === TYPES.METADATA_UPDATED) {
|
|
if (msg.nowPlaying) {
|
|
els.npTrack.textContent = formatNowPlaying(msg.nowPlaying);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 7. Cross-surface sync — if the New Tab Page (or another popup
|
|
// instance) picks a station, mirror it here without a reload.
|
|
chrome.storage.onChanged.addListener((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.btnPlay.disabled = !currentStation;
|
|
reflectFirstRunHint();
|
|
renderNowPlaying();
|
|
renderStations();
|
|
}
|
|
|
|
if (changes[STORAGE_KEYS.isPlaying]) {
|
|
playing = !!changes[STORAGE_KEYS.isPlaying].newValue;
|
|
reflectPlayButton();
|
|
}
|
|
});
|
|
|
|
// 8. Enable the play button if we have a station picked.
|
|
els.btnPlay.disabled = !currentStation;
|
|
}
|
|
|
|
function renderStations() {
|
|
if (!stations.length) {
|
|
els.list.innerHTML = '<li class="station-empty">No stations available.</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;
|
|
|
|
if (!filtered.length) {
|
|
els.list.innerHTML = '<li class="station-empty">No matches.</li>';
|
|
return;
|
|
}
|
|
|
|
els.list.innerHTML = '';
|
|
for (const s of filtered) {
|
|
const li = document.createElement('li');
|
|
li.className = 'station-row';
|
|
if (currentStation && currentStation.id === s.id) li.classList.add('is-active');
|
|
li.setAttribute('role', 'option');
|
|
li.dataset.id = s.id;
|
|
|
|
const img = document.createElement('img');
|
|
img.className = 'station-art';
|
|
img.alt = '';
|
|
img.src = s.artworkUrl || '';
|
|
img.onerror = () => { img.style.visibility = 'hidden'; };
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'station-meta';
|
|
|
|
const name = document.createElement('div');
|
|
name.className = 'station-name';
|
|
name.textContent = s.name;
|
|
|
|
const genre = document.createElement('div');
|
|
genre.className = 'station-genre';
|
|
genre.textContent = s.genre || s.description.slice(0, 60);
|
|
|
|
meta.appendChild(name);
|
|
meta.appendChild(genre);
|
|
li.appendChild(img);
|
|
li.appendChild(meta);
|
|
|
|
li.addEventListener('click', () => onPickStation(s));
|
|
|
|
els.list.appendChild(li);
|
|
}
|
|
}
|
|
|
|
function renderNowPlaying() {
|
|
if (currentStation) {
|
|
els.npStation.textContent = currentStation.name;
|
|
els.npTrack.textContent = playing ? 'Now playing — fetching metadata…' : 'Ready to play';
|
|
} else {
|
|
els.npStation.textContent = '—';
|
|
els.npTrack.textContent = 'Pick a station to begin';
|
|
}
|
|
}
|
|
|
|
async function onPickStation(station) {
|
|
currentStation = station;
|
|
els.btnPlay.disabled = false;
|
|
reflectFirstRunHint();
|
|
await chrome.storage.local.set({ [STORAGE_KEYS.currentId]: station.id });
|
|
renderNowPlaying();
|
|
renderStations();
|
|
// User explicitly picked → start playing.
|
|
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);
|
|
// Push current volume first so the first frame plays at the right level.
|
|
await sendToSW({ type: TYPES.SET_VOLUME, volume: Number(els.volume.value) / 100 });
|
|
// Pass a stripped station object — offscreen needs id/sourceId/name to
|
|
// poll metadata + label history entries; the rest is UI-only.
|
|
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');
|
|
// The offscreen poll loop will broadcast METADATA_UPDATED within ~1s,
|
|
// so we don't fire a separate one-shot fetch from here any more.
|
|
} catch (err) {
|
|
console.error(err);
|
|
setState('error');
|
|
els.npTrack.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 });
|
|
}
|
|
|
|
function reflectPlayButton() {
|
|
els.btnPlay.setAttribute('aria-pressed', playing ? 'true' : 'false');
|
|
els.btnPlay.textContent = playing ? '❚❚ Pause' : '▶ Play';
|
|
}
|
|
|
|
// v0.4.0 first-run UX hint — body class drives the pulse + arrow CSS in
|
|
// popup.css. Active when no station has been picked yet; cleared the
|
|
// moment a station is selected (here or in another surface via storage
|
|
// sync).
|
|
function reflectFirstRunHint() {
|
|
document.body.classList.toggle('is-first-run', !currentStation);
|
|
}
|
|
|
|
function setState(state) {
|
|
els.statePill.dataset.state = state;
|
|
els.statePill.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 });
|
|
}
|