feat: initial commit — RangerHQ Tuner v0.1.0 (Tier 1 MVP)

Chrome MV3 extension, browser-resident sibling to rangerhq-radio
(WP plugin). Plays SomaFM via the chrome.offscreen API + a source-
adapter pattern at src/sources/.

Architecture highlights:
- Audio runs in offscreen document — SW would get killed.
- Source-adapter pattern locks Tier 1 contract (RadioSource interface
  in src/sources/base-source.js). Adding a network = drop a file +
  register one line in src/sources/index.js.
- Vanilla JS, no build step. Pure ES modules.
- No telemetry, no third-party JS. Outbound only to somafm.com.
- Narrow permissions: offscreen + storage + somafm.com host_perms.
  No tabs, no <all_urls>, no webRequest.

22 files, ~30 min build following the saved plan at
~/.ranger-memory/projects/rangerhq-tuner-plan.md.

Tier 2 + Tier 3 (Web Store submission) not started.
This commit is contained in:
2026-06-08 23:31:29 +01:00
commit 38b6b8d3f7
20 changed files with 1001 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
/* RangerHQ Tuner popup — ~360x520, dark earthy palette echoing the
RangerHQ family brand (forest green + warm cream). Vanilla CSS,
no preprocessor, no build step. */
:root {
--bg: #0f1411;
--bg-soft: #1a221c;
--bg-row: #1f2823;
--bg-row-hi: #2a3530;
--fg: #e8e4d4;
--fg-muted: #97a094;
--accent: #6dbf7a; /* RangerHQ green */
--accent-dim: #2a7d3e;
--cream: #f4e9b7;
--danger: #c9685b;
--radius: 6px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
width: 360px;
background: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
line-height: 1.4;
}
/* Header */
.tuner-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--bg-soft);
border-bottom: 1px solid #000;
}
.tuner-brand {
display: flex;
align-items: center;
gap: 8px;
}
.tuner-brand h1 {
margin: 0;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.2px;
}
.tuner-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px var(--accent);
}
.tuner-state {
font-size: 11px;
color: var(--fg-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 8px;
border-radius: 999px;
background: var(--bg-row);
}
.tuner-state[data-state="playing"] {
color: var(--bg);
background: var(--accent);
}
.tuner-state[data-state="buffering"] {
color: var(--bg);
background: var(--cream);
}
.tuner-state[data-state="error"] {
color: var(--cream);
background: var(--danger);
}
/* Now playing */
.now-playing {
padding: 10px 12px;
background: var(--bg-soft);
border-bottom: 1px solid #000;
}
.np-station {
font-weight: 600;
font-size: 13px;
}
.np-track {
font-size: 12px;
color: var(--fg-muted);
margin-top: 2px;
min-height: 1.4em;
}
/* Controls */
.controls {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid #000;
}
.btn {
padding: 6px 14px;
font-size: 13px;
font-weight: 500;
border: 1px solid var(--accent-dim);
border-radius: var(--radius);
background: var(--bg-row);
color: var(--fg);
cursor: pointer;
}
.btn:hover:not(:disabled) {
background: var(--bg-row-hi);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent-dim);
}
.btn-primary[aria-pressed="true"] {
background: var(--accent);
color: var(--bg);
}
.vol-wrap {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.vol-label {
font-size: 11px;
color: var(--fg-muted);
}
#volume {
flex: 1;
accent-color: var(--accent);
}
/* Search */
.search-wrap {
display: block;
padding: 8px 12px 4px;
}
#search {
width: 100%;
padding: 6px 8px;
font-size: 12px;
background: var(--bg-row);
color: var(--fg);
border: 1px solid var(--bg-row-hi);
border-radius: var(--radius);
}
#search:focus {
outline: none;
border-color: var(--accent);
}
/* Station list */
.station-list {
margin: 0;
padding: 0;
list-style: none;
max-height: 280px;
overflow-y: auto;
}
.station-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid var(--bg-soft);
cursor: pointer;
}
.station-row:hover {
background: var(--bg-row);
}
.station-row.is-active {
background: var(--bg-row-hi);
border-left: 3px solid var(--accent);
padding-left: 9px;
}
.station-art {
width: 28px;
height: 28px;
border-radius: 4px;
background: var(--bg-soft);
flex-shrink: 0;
object-fit: cover;
}
.station-meta {
flex: 1;
min-width: 0;
}
.station-name {
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.station-genre {
font-size: 10px;
color: var(--fg-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.station-empty {
padding: 16px;
text-align: center;
color: var(--fg-muted);
font-size: 12px;
}
/* Footer */
.tuner-footer {
padding: 6px 12px;
background: var(--bg-soft);
border-top: 1px solid #000;
font-size: 10px;
color: var(--fg-muted);
text-align: center;
}
/* Scrollbar styling — subtle, on-brand */
.station-list::-webkit-scrollbar {
width: 6px;
}
.station-list::-webkit-scrollbar-thumb {
background: var(--bg-row-hi);
border-radius: 3px;
}
.station-list::-webkit-scrollbar-track {
background: transparent;
}
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>RangerHQ Tuner</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<header class="tuner-header">
<div class="tuner-brand">
<span class="tuner-dot" aria-hidden="true"></span>
<h1>RangerHQ Tuner</h1>
</div>
<span class="tuner-state" id="state-pill" aria-live="polite">idle</span>
</header>
<section class="now-playing" aria-label="Now playing">
<div class="np-station" id="np-station"></div>
<div class="np-track" id="np-track">Pick a station to begin</div>
</section>
<section class="controls" aria-label="Playback controls">
<button id="btn-play" class="btn btn-primary" aria-pressed="false" disabled>▶ Play</button>
<label class="vol-wrap">
<span class="vol-label">Vol</span>
<input type="range" id="volume" min="0" max="100" value="70" aria-label="Volume">
</label>
</section>
<section class="stations" aria-label="Stations">
<label class="search-wrap">
<input type="search" id="search" placeholder="Filter stations…" aria-label="Filter stations">
</label>
<ul id="station-list" class="station-list" role="listbox">
<li class="station-empty">Loading stations…</li>
</ul>
</section>
<footer class="tuner-footer">
<span>SomaFM • indie radio • no telemetry</span>
</footer>
<script type="module" src="popup.js"></script>
</body>
</html>
+231
View File
@@ -0,0 +1,231 @@
// 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'),
};
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;
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();
});
// 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. 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;
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 });
const resp = await sendToSW({ type: TYPES.PLAY, streamUrl });
if (!resp?.ok) throw new Error(resp?.error || 'PLAY failed');
// Fire-and-forget now-playing fetch.
source.getNowPlaying(currentStation).then(np => {
if (np) els.npTrack.textContent = formatNowPlaying(np);
}).catch(() => {});
} 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';
}
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 });
}