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:
@@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# .crx is the packed extension build, .pem is the signing key.
|
||||
# Losing the .pem means you can never update the same extension ID,
|
||||
# so it lives outside git and gets backed up separately.
|
||||
*.crx
|
||||
*.pem
|
||||
@@ -0,0 +1,83 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to **RangerHQ Tuner** are documented here.
|
||||
Format: [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) — versioning: [SemVer](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Planned — New Tab Page override (Tier 2.5)
|
||||
- Replace Chrome's default New Tab Page with a RangerHQ-branded version that surfaces the player, current track, and a quick-station picker.
|
||||
- Adds `chrome_url_overrides.newtab` to `manifest.json` pointing at `src/newtab/newtab.html`.
|
||||
- Reuses the popup's CSS palette + source-adapter pattern — no new architectural concepts.
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] — 2026-06-08
|
||||
|
||||
### Added — Tier 1 MVP (Buddy is alive — er, Tuner is alive)
|
||||
|
||||
**Buddy's browser cousin lives.** First release of RangerHQ Tuner — a Chrome Manifest V3 extension that plays SomaFM internet radio from your toolbar. Sibling to [`rangerhq-radio`](https://git.davidtkeane.com/ranger/rangerhq-radio) (the WordPress version live on wp.org since 2026-06-04). Same brand idea, different surface: WP version lives in admin pages, Chrome version lives one toolbar-click away no matter what you're doing.
|
||||
|
||||
#### Architecture
|
||||
|
||||
- **Manifest V3** — uses the `chrome.offscreen` API to host the `<audio>` element in a hidden document. Service workers can't host audio in MV3 (they get killed when idle), so a one-off offscreen document is the only supported pattern. Working code in `src/offscreen/offscreen.js` + `src/background/service-worker.js`.
|
||||
- **Source-adapter pattern** at `src/sources/` — every radio network is a single file conforming to the `RadioSource` interface in `base-source.js`. Adding a new network = drop a new file + register one line in `src/sources/index.js`. Popup, SW, and offscreen never know which network is which.
|
||||
- **Vanilla JS, no build step** — pure ES modules loaded directly. No webpack, no Vite, no `npm install`. Same ethos as the RangerHQ WP family (hand-rolled PHP).
|
||||
- **No telemetry, no third-party JS** — everything is local. Only outbound network calls are to SomaFM's public endpoints (catalogue + stream metadata).
|
||||
|
||||
#### Features
|
||||
|
||||
- **Toolbar popup** showing a searchable SomaFM station list (~150 channels) with artwork, name, and genre.
|
||||
- **Play / Pause / Volume** controls. Volume persists across browser restarts.
|
||||
- **Last station** picked is remembered — Tuner remembers where you left off.
|
||||
- **Now-playing metadata** displayed from SomaFM's per-channel song history endpoint.
|
||||
- **Catalogue cached** in `chrome.storage.local` for 6 hours to make popup re-opens instant.
|
||||
- **Audio continues after popup closes** — the offscreen document owns the playback, popup is just a view.
|
||||
- **RangerHQ helmet** icon in the Chrome toolbar (resized from `src/assets/img/ranger.png` to 16/32/48/128 PNGs with a dark `#1a221c` padded background that matches the popup palette).
|
||||
|
||||
#### Files (22 created)
|
||||
|
||||
```
|
||||
rangerhq-tuner/
|
||||
├── manifest.json
|
||||
├── README.md
|
||||
├── CHANGELOG.md
|
||||
├── .gitignore
|
||||
└── src/
|
||||
├── assets/
|
||||
│ ├── icons/icon-{16,32,48,128}.png (toolbar icons)
|
||||
│ └── img/ranger.png (master helmet logo, 257×275)
|
||||
├── background/service-worker.js (message router only)
|
||||
├── offscreen/offscreen.{html,js} (audio host — the MV3 gotcha solved)
|
||||
├── popup/popup.{html,css,js} (the toolbar UI)
|
||||
├── sources/
|
||||
│ ├── base-source.js (RadioSource interface contract)
|
||||
│ ├── somafm.js (first concrete adapter)
|
||||
│ └── index.js (registry)
|
||||
└── lib/
|
||||
├── messages.js (type + target constants)
|
||||
└── playlist-parser.js (.pls parser)
|
||||
```
|
||||
|
||||
#### Manifest permissions (narrow on purpose)
|
||||
|
||||
```json
|
||||
"permissions": ["offscreen", "storage"],
|
||||
"host_permissions": ["https://somafm.com/*", "https://*.somafm.com/*"]
|
||||
```
|
||||
|
||||
No `tabs`, no `<all_urls>`, no `webRequest`. Smallest permission ask possible = easiest Web Store review.
|
||||
|
||||
#### Architecture / status
|
||||
|
||||
- **Tier 1 MVP** — shipped this commit.
|
||||
- **Tier 2** (planned) — full UX polish, favourites via `chrome.storage.sync`, now-playing polling loop, `.m3u` parser, `.crx` packaging, second source adapter stub.
|
||||
- **Tier 3** (planned) — Chrome Web Store submission. See `~/.ranger-memory/docs/` for the per-family submission checklist; Chrome Web Store specifics tracked in the user's memory under `reference_chrome_web_store_rules`.
|
||||
|
||||
#### Why this exists
|
||||
|
||||
David has Chrome open essentially all day. The WP version of RangerHQ Radio requires going to admin pages to use; the toolbar version is one click away regardless of context. Same code-level effort, dramatically higher daily-use payoff. The "best thing we done so far" verdict on first sound (2026-06-08 evening) confirmed the bet.
|
||||
|
||||
#### Rangers lead the way 🪖☕🎵
|
||||
@@ -0,0 +1,43 @@
|
||||
# RangerHQ Tuner
|
||||
|
||||
Lightweight indie internet radio player for Chrome. Plays SomaFM in any browser tab. Manifest V3, vanilla JS, no telemetry.
|
||||
|
||||
Sibling to [rangerhq-radio](https://git.davidtkeane.com/ranger/rangerhq-radio) (the WordPress version).
|
||||
|
||||
## Tier 1 — MVP (current)
|
||||
|
||||
- ✅ Manifest V3 + Offscreen API audio
|
||||
- ✅ Loads all SomaFM channels from `channels.json`
|
||||
- ✅ Pick a station, click Play, audio runs in background
|
||||
- ✅ Volume + state persisted across popup open/close
|
||||
- ✅ Catalogue cached 6h in `chrome.storage.local`
|
||||
- ✅ Source-adapter pattern in place for future networks
|
||||
|
||||
## Install (developer mode)
|
||||
|
||||
1. Open `chrome://extensions`
|
||||
2. Toggle **Developer mode** on (top right)
|
||||
3. Click **Load unpacked** → pick this folder (`rangerhq-tuner/`)
|
||||
4. Pin the extension to your toolbar (puzzle icon → pin)
|
||||
5. Click the toolbar icon → pick a SomaFM station → ▶ Play
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
rangerhq-tuner/
|
||||
├── manifest.json
|
||||
└── src/
|
||||
├── background/service-worker.js # message router, no audio here
|
||||
├── offscreen/offscreen.{html,js} # the <audio> element host (MV3 needs this)
|
||||
├── popup/popup.{html,css,js} # the toolbar UI
|
||||
├── sources/ # extensibility seam
|
||||
│ ├── base-source.js # RadioSource interface (JSDoc)
|
||||
│ ├── somafm.js # first concrete adapter
|
||||
│ └── index.js # registry
|
||||
├── lib/ # shared utilities
|
||||
└── assets/icons/ # 16/32/48/128 PNGs
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
GPL-2.0-or-later — matches the rest of the RangerHQ family.
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "RangerHQ Tuner",
|
||||
"version": "0.1.0",
|
||||
"description": "Lightweight indie internet radio player. Starts with SomaFM, extensible to more networks.",
|
||||
"author": "David Keane",
|
||||
"homepage_url": "https://davidtkeane.com/rangerhq-tuner",
|
||||
"icons": {
|
||||
"16": "src/assets/icons/icon-16.png",
|
||||
"32": "src/assets/icons/icon-32.png",
|
||||
"48": "src/assets/icons/icon-48.png",
|
||||
"128": "src/assets/icons/icon-128.png"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "RangerHQ Tuner",
|
||||
"default_popup": "src/popup/popup.html",
|
||||
"default_icon": {
|
||||
"16": "src/assets/icons/icon-16.png",
|
||||
"32": "src/assets/icons/icon-32.png"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "src/background/service-worker.js",
|
||||
"type": "module"
|
||||
},
|
||||
"permissions": ["offscreen", "storage"],
|
||||
"host_permissions": [
|
||||
"https://somafm.com/*",
|
||||
"https://*.somafm.com/*"
|
||||
],
|
||||
"minimum_chrome_version": "116"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 930 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,39 @@
|
||||
// RangerHQ Tuner — Service Worker.
|
||||
// Job: open the offscreen audio document on demand and route messages
|
||||
// between the popup (UI) and the offscreen document (audio engine).
|
||||
// We hold no state here — Chrome will kill this worker at any time.
|
||||
|
||||
const OFFSCREEN_PATH = 'src/offscreen/offscreen.html';
|
||||
|
||||
async function ensureOffscreen() {
|
||||
// hasDocument is the official MV3 way to check. Optional-chained
|
||||
// for older Chrome safety (we still require 116+ via the manifest).
|
||||
const exists = await chrome.offscreen.hasDocument?.();
|
||||
if (exists) return;
|
||||
await chrome.offscreen.createDocument({
|
||||
url: OFFSCREEN_PATH,
|
||||
reasons: ['AUDIO_PLAYBACK'],
|
||||
justification: 'Persistent audio playback for internet radio streams.',
|
||||
});
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
// Only react to messages targeted at the service worker.
|
||||
if (msg.target !== 'sw') return;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await ensureOffscreen();
|
||||
// Re-route the message to the offscreen document.
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
...msg,
|
||||
target: 'offscreen',
|
||||
});
|
||||
sendResponse(response);
|
||||
} catch (err) {
|
||||
sendResponse({ ok: false, error: err.message });
|
||||
}
|
||||
})();
|
||||
|
||||
return true; // keep the channel open for async sendResponse
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// Message type + target constants. Single source of truth — popup,
|
||||
// service worker, and offscreen document all import from here so we
|
||||
// can't drift on string typos.
|
||||
|
||||
export const TARGETS = Object.freeze({
|
||||
SW: 'sw',
|
||||
OFFSCREEN: 'offscreen',
|
||||
POPUP: 'popup',
|
||||
});
|
||||
|
||||
export const TYPES = Object.freeze({
|
||||
PLAY: 'PLAY',
|
||||
PAUSE: 'PAUSE',
|
||||
SET_VOLUME: 'SET_VOLUME',
|
||||
GET_STATE: 'GET_STATE',
|
||||
STATE_CHANGED: 'STATE_CHANGED',
|
||||
METADATA_UPDATED: 'METADATA_UPDATED',
|
||||
ERROR: 'ERROR',
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// Minimal .pls parser. SomaFM publishes channels as .pls playlist
|
||||
// files, e.g.:
|
||||
// [playlist]
|
||||
// numberofentries=4
|
||||
// File1=https://ice1.somafm.com/groovesalad-128-mp3
|
||||
// Title1=SomaFM: Groove Salad ...
|
||||
// ...
|
||||
// We extract the FileN entries in order; first one is the preferred
|
||||
// stream. .m3u parser deferred to Tier 2.
|
||||
|
||||
export function parsePls(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const streams = [];
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^File\d+=(.+)$/i);
|
||||
if (m) streams.push(m[1].trim());
|
||||
}
|
||||
return streams;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>RangerHQ Tuner — Audio Host</title>
|
||||
</head>
|
||||
<body>
|
||||
<audio id="player" preload="none"></audio>
|
||||
<script type="module" src="offscreen.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
// RangerHQ Tuner — Offscreen audio host.
|
||||
// Owns the <audio> element. MV3 service workers can't host audio
|
||||
// (they get killed when idle), so all live media lives here.
|
||||
|
||||
const audio = document.getElementById('player');
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.target !== 'offscreen') return;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'PLAY':
|
||||
audio.src = msg.streamUrl;
|
||||
audio.play()
|
||||
.then(() => sendResponse({ ok: true }))
|
||||
.catch(err => sendResponse({ ok: false, error: err.message }));
|
||||
return true; // keep channel open for async sendResponse
|
||||
|
||||
case 'PAUSE':
|
||||
audio.pause();
|
||||
sendResponse({ ok: true });
|
||||
break;
|
||||
|
||||
case 'SET_VOLUME':
|
||||
audio.volume = Math.max(0, Math.min(1, msg.volume));
|
||||
sendResponse({ ok: true });
|
||||
break;
|
||||
|
||||
case 'GET_STATE':
|
||||
sendResponse({
|
||||
ok: true,
|
||||
paused: audio.paused,
|
||||
currentSrc: audio.currentSrc,
|
||||
volume: audio.volume,
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Broadcast state changes to anyone listening (popup, SW).
|
||||
// If popup is closed, the send fails silently — that's fine.
|
||||
audio.addEventListener('playing', () => broadcast({ type: 'STATE_CHANGED', state: 'playing' }));
|
||||
audio.addEventListener('pause', () => broadcast({ type: 'STATE_CHANGED', state: 'paused' }));
|
||||
audio.addEventListener('waiting', () => broadcast({ type: 'STATE_CHANGED', state: 'buffering' }));
|
||||
audio.addEventListener('error', () => broadcast({ type: 'ERROR', code: audio.error?.code }));
|
||||
|
||||
function broadcast(payload) {
|
||||
chrome.runtime.sendMessage({ target: 'popup', ...payload }).catch(() => {});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// RadioSource interface contract (JSDoc-typed, no class needed).
|
||||
// Every network adapter under src/sources/ MUST conform to this shape.
|
||||
// Lock this at end of Tier 1 — drift breaks every subsequent adapter.
|
||||
|
||||
/**
|
||||
* @typedef {Object} Station
|
||||
* @property {string} id Namespaced identifier, e.g. "somafm:groovesalad"
|
||||
* @property {string} sourceId The network adapter id, e.g. "somafm"
|
||||
* @property {string} name Display name shown in the popup
|
||||
* @property {string} description Short blurb
|
||||
* @property {string} genre Comma-separated genres or single tag
|
||||
* @property {string} artworkUrl Square artwork URL (or empty string)
|
||||
* @property {string[]} streamUrls Resolved direct stream URLs, ordered by preference
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} NowPlaying
|
||||
* @property {string} title
|
||||
* @property {string} artist
|
||||
* @property {string} [album]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RadioSource
|
||||
* @property {string} id Adapter id, e.g. "somafm"
|
||||
* @property {string} displayName Human-readable network name
|
||||
* @property {() => Promise<Station[]>} listStations
|
||||
* @property {(station: Station) => Promise<NowPlaying|null>} getNowPlaying
|
||||
* @property {(station: Station) => Promise<string>} resolveStreamUrl
|
||||
*/
|
||||
|
||||
// This file is intentionally documentation-only at runtime.
|
||||
// Importing it has no side effects.
|
||||
export {};
|
||||
@@ -0,0 +1,35 @@
|
||||
// Source registry. Every RadioSource adapter registers itself here.
|
||||
// Popup/SW/offscreen never import individual adapters — they query
|
||||
// the registry. To add a new network: write a new adapter file, then
|
||||
// add one line to REGISTERED below.
|
||||
|
||||
import { somaFM } from './somafm.js';
|
||||
|
||||
/** @type {import('./base-source.js').RadioSource[]} */
|
||||
const REGISTERED = [
|
||||
somaFM,
|
||||
];
|
||||
|
||||
export function listSources() {
|
||||
return REGISTERED.map(s => ({ id: s.id, displayName: s.displayName }));
|
||||
}
|
||||
|
||||
export function getSource(id) {
|
||||
return REGISTERED.find(s => s.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate stations across every registered source. Returns a flat
|
||||
* list. Each Station already carries its sourceId so the popup knows
|
||||
* which adapter to call back for play/now-playing.
|
||||
*/
|
||||
export async function listAllStations() {
|
||||
const results = await Promise.allSettled(REGISTERED.map(s => s.listStations()));
|
||||
/** @type {import('./base-source.js').Station[]} */
|
||||
const stations = [];
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'fulfilled') stations.push(...r.value);
|
||||
else console.warn(`Source "${REGISTERED[i].id}" failed:`, r.reason);
|
||||
});
|
||||
return stations;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// SomaFM source adapter — implements the RadioSource contract from
|
||||
// base-source.js. SomaFM is a listener-supported indie radio network
|
||||
// that publicly exposes a station catalogue (channels.json) and per-
|
||||
// station playlist files (.pls) — no API key required.
|
||||
//
|
||||
// Reference: https://somafm.com/
|
||||
|
||||
import { parsePls } from '../lib/playlist-parser.js';
|
||||
|
||||
const CHANNELS_URL = 'https://somafm.com/channels.json';
|
||||
|
||||
/** @type {import('./base-source.js').RadioSource} */
|
||||
export const somaFM = {
|
||||
id: 'somafm',
|
||||
displayName: 'SomaFM',
|
||||
|
||||
async listStations() {
|
||||
const res = await fetch(CHANNELS_URL, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`SomaFM channels.json HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const channels = Array.isArray(data?.channels) ? data.channels : [];
|
||||
|
||||
return channels.map(ch => ({
|
||||
id: `somafm:${ch.id}`,
|
||||
sourceId: 'somafm',
|
||||
name: ch.title || ch.id,
|
||||
description: ch.description || '',
|
||||
genre: ch.genre || '',
|
||||
artworkUrl: ch.image || '',
|
||||
// We don't resolve the stream here — that happens lazily on play
|
||||
// (one extra HTTP fetch per station change, beats ~150 upfront).
|
||||
streamUrls: [],
|
||||
// Keep the raw playlists list around so resolveStreamUrl can pick
|
||||
// the best one without re-fetching the catalogue.
|
||||
_playlists: Array.isArray(ch.playlists) ? ch.playlists : [],
|
||||
}));
|
||||
},
|
||||
|
||||
async resolveStreamUrl(station) {
|
||||
// Prefer mp3 + highest available quality. SomaFM offers aac too,
|
||||
// but mp3 has the broadest browser support.
|
||||
const playlists = station._playlists || [];
|
||||
const preferred =
|
||||
playlists.find(p => p.format === 'mp3' && p.quality === 'highest') ||
|
||||
playlists.find(p => p.format === 'mp3') ||
|
||||
playlists[0];
|
||||
|
||||
if (!preferred?.url) {
|
||||
throw new Error(`No playable playlist for ${station.name}`);
|
||||
}
|
||||
|
||||
const res = await fetch(preferred.url, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`SomaFM .pls HTTP ${res.status}`);
|
||||
const text = await res.text();
|
||||
const streams = parsePls(text);
|
||||
|
||||
if (!streams.length) {
|
||||
throw new Error(`Empty playlist for ${station.name}`);
|
||||
}
|
||||
return streams[0];
|
||||
},
|
||||
|
||||
async getNowPlaying(station) {
|
||||
// SomaFM publishes per-channel song history at
|
||||
// https://somafm.com/songs/{shortId}.json
|
||||
// The first entry is the current track.
|
||||
const shortId = station.id.split(':')[1];
|
||||
if (!shortId) return null;
|
||||
try {
|
||||
const res = await fetch(`https://somafm.com/songs/${shortId}.json`, { cache: 'no-store' });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const songs = Array.isArray(data?.songs) ? data.songs : [];
|
||||
const cur = songs[0];
|
||||
if (!cur) return null;
|
||||
return {
|
||||
title: cur.title || '',
|
||||
artist: cur.artist || '',
|
||||
album: cur.album || '',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user