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