feat: v0.3.0 — history, 4-button search, options page, newtab override

Web Store submission target. Mirrors rangerhq-radio's track-history pattern
(inc/history.php) so the family stays coherent across surfaces.

Highlights
- New Tab Page override (Tier 2.5) — Chrome's default new tab replaced with
  a RangerHQ-branded landing showing the player, current track, quick chips,
  searchable browse list, and now history + favourites tabs.
- Track history + favourites — capped FIFO 500, dedup against last entry,
  skip "(unknown)" artist (SomaFM dead-air). Stored in chrome.storage.local
  under tuner.history + tuner.favourites.
- 4-button search per entry — Spotify / YouTube / Apple Music / Bandcamp.
  Pure public-search-URL link-outs in a new tab, NO auth, NO API keys, NO
  quota, NO third-party SDK embedded.
- Options page (chrome://extensions → details → options) — live stats,
  history cap slider (50-500), Clear history / Clear favourites / Clear
  EVERYTHING buttons, About panel with Gitea + davidtkeane.com links.
- Popup nav row — Open in tab / History (#hash deep link) / Settings,
  using chrome.tabs.create + chrome.runtime.openOptionsPage. No new perms.
- Cross-surface sync — popup ↔ newtab listen on chrome.storage.onChanged
  for tuner.currentStationId / tuner.isPlaying / history / favourites.
- Storage gateway — offscreen doc can't reliably reach chrome.storage in
  some Chrome versions, so it sends LOG_TRACK_REQUEST to the SW which
  does the write. history.js also defensively guards every storage call.
- Metadata latency fix — polling now starts immediately on PLAY, in
  parallel with audio buffer fill. First track display drops from
  ~10-15s to ~1-2s.

Permissions unchanged
- Still ["offscreen", "storage"] + somafm.com host only.
- chrome.tabs.create works on our own extension URLs without "tabs" perm.
- No webRequest, no <all_urls>, no third-party SDK.

Bumped from 0.1.0 (last tag on Gitea) directly to 0.3.0.
v0.2.0 (newtab + clock) was a working local build but never tagged;
its features ship together with v0.3.0 in this single commit.
This commit is contained in:
2026-06-09 00:23:36 +01:00
parent 38b6b8d3f7
commit ad43df87c0
15 changed files with 2266 additions and 44 deletions
+98 -18
View File
@@ -1,48 +1,128 @@
// 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.
// Owns the <audio> element and the 25-second metadata polling loop.
// MV3 service workers can't host audio (they get killed when idle),
// so all live media + polling lives here.
import { TARGETS, TYPES } from '../lib/messages.js';
import { getSource } from '../sources/index.js';
// NOTE: we intentionally do NOT import history.js here. chrome.storage is
// not reliably available in offscreen documents across Chrome versions,
// so we route writes through the service worker via LOG_TRACK_REQUEST.
const audio = document.getElementById('player');
// Poll interval matches rangerhq-radio's WP plugin — battle-tested at 25s.
const POLL_MS = 25 * 1000;
let pollTimer = null;
let currentStation = null; // { id, sourceId, name, ... }
let lastSeenSig = ''; // dedupe metadata broadcasts within a session
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.target !== 'offscreen') return;
if (msg.target !== TARGETS.OFFSCREEN) return;
switch (msg.type) {
case 'PLAY':
case TYPES.PLAY:
currentStation = msg.station || null;
audio.src = msg.streamUrl;
// Kick off metadata polling IMMEDIATELY — in parallel with audio
// buffering. The first poll fires now and broadcasts METADATA_UPDATED
// as soon as SomaFM responds (~500ms), instead of waiting 2-5s for
// the audio HTTP stream to fill its buffer.
startPolling();
audio.play()
.then(() => sendResponse({ ok: true }))
.catch(err => sendResponse({ ok: false, error: err.message }));
return true; // keep channel open for async sendResponse
.catch(err => {
sendResponse({ ok: false, error: err.message });
stopPolling();
});
return true; // async sendResponse
case 'PAUSE':
case TYPES.PAUSE:
audio.pause();
stopPolling();
sendResponse({ ok: true });
break;
case 'SET_VOLUME':
case TYPES.SET_VOLUME:
audio.volume = Math.max(0, Math.min(1, msg.volume));
sendResponse({ ok: true });
break;
case 'GET_STATE':
case TYPES.GET_STATE:
sendResponse({
ok: true,
paused: audio.paused,
paused: audio.paused,
currentSrc: audio.currentSrc,
volume: audio.volume,
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 }));
/* ---------- Audio events ---------- */
audio.addEventListener('playing', () => broadcast({ type: TYPES.STATE_CHANGED, state: 'playing' }));
audio.addEventListener('pause', () => broadcast({ type: TYPES.STATE_CHANGED, state: 'paused' }));
audio.addEventListener('waiting', () => broadcast({ type: TYPES.STATE_CHANGED, state: 'buffering' }));
audio.addEventListener('error', () => {
stopPolling();
broadcast({ type: TYPES.ERROR, code: audio.error?.code });
});
function broadcast(payload) {
chrome.runtime.sendMessage({ target: 'popup', ...payload }).catch(() => {});
chrome.runtime.sendMessage({ target: TARGETS.POPUP, ...payload }).catch(() => {});
}
/* ---------- Metadata polling + history logging ---------- */
function startPolling() {
stopPolling();
pollNow(); // fire immediately so UI gets first metadata fast
pollTimer = setInterval(pollNow, POLL_MS);
}
function stopPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = null;
lastSeenSig = '';
}
async function pollNow() {
if (!currentStation) return;
const source = getSource(currentStation.sourceId);
if (!source) return;
let np;
try {
np = await source.getNowPlaying(currentStation);
} catch {
return;
}
if (!np || !np.title) return;
// Broadcast every poll where we have metadata, so the UI's track line
// is always current (even if the song hasn't changed, the popup might
// have been opened fresh and needs to render).
broadcast({ type: TYPES.METADATA_UPDATED, nowPlaying: np, station: currentStation });
// Log to history — but only when the (artist|title) changed since we
// last saw it within this play-session. The history.js layer also
// dedups against the persisted last entry, so this is belt + braces.
const sig = `${(np.artist || '').toLowerCase().trim()}|${(np.title || '').toLowerCase().trim()}`;
if (sig === lastSeenSig) return;
lastSeenSig = sig;
// Ask the service worker to do the actual storage write. The SW will
// broadcast TRACK_LOGGED back to the UI surfaces on success.
chrome.runtime.sendMessage({
target: TARGETS.SW,
type: TYPES.LOG_TRACK_REQUEST,
entry: {
artist: np.artist,
title: np.title,
station: currentStation.name,
stationId: currentStation.id,
},
}).catch(() => { /* SW asleep is fine — next poll will retry */ });
}