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
+11
View File
@@ -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>
+48
View File
@@ -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(() => {});
}