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,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(() => {});
|
||||
}
|
||||
Reference in New Issue
Block a user