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 86d7c80106
15 changed files with 2266 additions and 44 deletions
+127 -4
View File
@@ -7,10 +7,133 @@ Format: [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) — versi
## [Unreleased] ## [Unreleased]
### Planned — New Tab Page override (Tier 2.5) ### Planned — Tier 3 (Chrome Web Store submission)
- Replace Chrome's default New Tab Page with a RangerHQ-branded version that surfaces the player, current track, and a quick-station picker. - Listing assets: 1280x800 screenshots, 440x280 promo tile
- Adds `chrome_url_overrides.newtab` to `manifest.json` pointing at `src/newtab/newtab.html`. - Privacy policy URL published at `davidtkeane.com/rangerhq-tuner/privacy`
- Reuses the popup's CSS palette + source-adapter pattern — no new architectural concepts. - 2-Step Verification on Google account
- $5 dev fee + submission
---
## [0.3.0] — 2026-06-08
### Added — Track history + 4-button search + Options page (Web Store target)
This is the version that goes to the Chrome Web Store. Mirrors the rangerhq-radio WordPress plugin's history feature so the family stays coherent across surfaces.
#### Track history
- **`src/lib/history.js`** — helpers for history/favourites/search-URL/dedup/cap/clear, mirroring `inc/history.php` in the WP plugin.
- **Storage:** `tuner.history` (FIFO, capped at 500 by default) and `tuner.favourites` (uncapped) in `chrome.storage.local`. Cap configurable 50500 on the Options page.
- **Entry shape** (mirror WP plugin exactly): `{ artist, title, station, stationId, at }` where `at = Date.now()`.
- **Dedup against the LAST entry only** so consecutive identical now-playing reads don't pile up.
- **Skip `(unknown)` artist** — SomaFM's dead-air/promo placeholder doesn't pollute the log.
#### Metadata polling
- **`src/offscreen/offscreen.js`** — the offscreen document (where audio lives) now polls SomaFM's per-channel song endpoint every 25 s while playing. On `PLAY`, polling starts (immediate first fetch + interval). On `PAUSE` or `ERROR`, polling stops. Within-session dedup prevents redundant logs when the same song stays current across polls.
- **Why the offscreen doc, not the SW:** the SW gets killed when idle; the offscreen doc is alive while audio plays. This is the right home for the loop.
- New message types in `src/lib/messages.js`: `TRACK_LOGGED`, `FAVOURITE_TOGGLED`, `STORAGE_WIPED`.
#### 4-button search (the headline feature)
- Every track row in History and Favourites gets four search-link buttons that open the major services' public search pages in a new tab:
| Service | URL pattern |
|---|---|
| Spotify | `https://open.spotify.com/search/{enc}` |
| YouTube | `https://www.youtube.com/results?search_query={enc}` |
| Apple Music | `https://music.apple.com/search?term={enc}` |
| Bandcamp | `https://bandcamp.com/search?q={enc}` |
Where `enc = encodeURIComponent(artist + " " + title)`. **Zero auth, zero API keys, zero quota, zero ToS gray area.** RangerHQ Tuner doesn't play these services — it opens the search results page and the user picks. Same approach as the rangerhq-radio WP plugin.
- Each link is brand-coloured (muted on the dark palette) — Spotify green, YouTube red, Apple pink, Bandcamp cyan. `target="_blank"` + `rel="noopener noreferrer"`.
#### Tabbed New Tab Page
- The New Tab Page's bottom section is now a **3-tab control**: **Stations · History · Favourites**, plus a ⚙ icon that opens the Options page.
- History tab: newest first, with track title, artist (accent green), `station · 2m ago` meta line, star toggle, and the 4 search links.
- Favourites tab: same shape, all your starred tracks.
- Each tab has its own "Clear …" button in the toolbar (confirm dialog).
#### Options page (NEW)
- Registered via `"options_page": "src/options/options.html"` in the manifest. Opens via `chrome://extensions` → details → Extension options OR via the ⚙ on the New Tab Page.
- **Local data card:** live stats (History size, Favourites size, Stations cached, Total `tuner.*` bytes), history-cap slider (50500 step 50), and three buttons:
- `Clear history` — drops `tuner.history` only.
- `Clear favourites` — drops `tuner.favourites` only.
- `Clear EVERYTHING` (danger styling) — wipes every `tuner.*` key in `chrome.storage.local`.
- **Playback card:** current volume %, last-played station name (read-only).
- **About card:** version, Gitea repo link, davidtkeane.com link, SomaFM link, licence reminder.
- A small toast message confirms each destructive action.
- The page listens for `chrome.storage.onChanged` so stats stay live if you wipe from somewhere else.
#### Manifest
- Version: `0.2.0``0.3.0`.
- Description updated to mention the 4-button search affordance (helps Web Store reviewers see the single-purpose statement matches the listing).
- Added `"options_page": "src/options/options.html"`.
- **No new permissions, no new host permissions.** Still `["offscreen", "storage"]` + `somafm.com` only. Search-link clicks open in a new browser tab — that's the user's navigation, not an API call from the extension.
#### Cross-surface state sync
The popup and the New Tab Page now stay in lockstep without manual reloads. Both surfaces subscribe to `chrome.storage.onChanged` and re-render when:
- `tuner.currentStationId` changes (someone in the other surface picked a station)
- `tuner.isPlaying` changes (play/pause from anywhere)
- `tuner.history` or `tuner.favourites` mutates (re-render the relevant tab)
Single source of truth = `chrome.storage.local`. No more drift between popup and New Tab.
#### Storage gateway architecture (MV3 safety net)
Offscreen documents don't reliably have access to `chrome.storage` across Chrome versions, so all storage writes from the offscreen audio host are routed through the service worker via a new `LOG_TRACK_REQUEST` message type. The SW (which always has `chrome.storage`) does the actual `tuner.history` write and broadcasts `TRACK_LOGGED` back to the UI surfaces.
As belt-and-braces, every function in `src/lib/history.js` now checks for `chrome.storage` availability before using it and returns sensible defaults if absent — so the module is safe to import from any extension context.
#### Metadata latency fix (parallelised audio + polling)
When PLAY arrives at the offscreen doc, metadata polling now starts **immediately** in parallel with the audio HTTP buffer fill, rather than waiting for `audio.play()` to resolve. First-track display time drops from ~10-15 seconds (audio buffer + then poll) to ~1-2 seconds (poll racing in parallel with buffer fill).
#### Popup quick-link nav
The toolbar popup now has a three-button nav row above the footer:
- **⎘ Open in tab** — `chrome.tabs.create({url: chrome.runtime.getURL('src/newtab/newtab.html')})` opens the full Tuner UI as a regular pinnable tab.
- **♪ History** — same as Open in tab but with `#history` hash so the History pane is pre-selected. `newtab.js` reads `location.hash` on init and respects it.
- **⚙ Settings** — `chrome.runtime.openOptionsPage()` opens the Options page directly.
No new permissions: `chrome.tabs.create()` works on our own extension URLs without the `tabs` permission. The Web Store permission profile stays `["offscreen", "storage"]` + `somafm.com` host only.
#### Why this is the Web Store version
- Clear single-purpose statement now obvious: *"Plays SomaFM internet radio, logs heard tracks, and provides search-link shortcuts to Spotify/YouTube/Apple Music/Bandcamp."*
- Privacy story stays clean: "collects nothing, stores everything locally, no remote storage" — every Options-page checkbox stays "does not collect" on the Dashboard.
- The four service buttons are pure `<a href>` link-outs — Web Store reviewers can verify they don't embed third-party SDKs.
- All inline styles are gone (lessons from `rangerhq-buddy v0.1.5`).
- Accessibility: every button has an `aria-label`, every interactive element is reachable by keyboard, focus rings visible, contrast ≥ 4.5:1.
- Storage operations are SW-gated, so even Chrome versions with the offscreen-doc storage quirk run cleanly.
---
## [0.2.0] — 2026-06-08
### Added — New Tab Page override (Tier 2.5)
Replace Chrome's default New Tab Page with a RangerHQ-branded landing that surfaces the player, current track, a quick-station chip row, and a searchable browse list. Same `chrome.offscreen` audio pipeline as the popup — the New Tab Page is just a second view onto the same playback state. Live ticking clock (HH:MM in bold + small dim seconds) updates every second; date renders in the user's locale.
Note: v0.2.0 was a working local build but was never tagged on Gitea — its features ship to the world together with v0.3.0 in a single commit.
- **`manifest.json`**: added
```json
"chrome_url_overrides": { "newtab": "src/newtab/newtab.html" }
```
Description string updated to mention the optional New Tab Page replacement (Web Store reviewers prefer overrides to be disclosed in the description).
- **`src/newtab/newtab.html`** — full-viewport landing with header (helmet + clock), large now-playing block, generous Play/Volume controls, quick-station chips, searchable browse list, footer linking to `davidtkeane.com` + Gitea.
- **`src/newtab/newtab.css`** — same earthy palette as the popup but laid out for a full screen. Subtle helmet watermark (2.5% opacity) in the background. Responsive shrink-to-fit at `max-height: 700px`.
- **`src/newtab/newtab.js`** — reuses `src/lib/messages.js`, `src/sources/index.js`, and the same `chrome.storage.local` keys as the popup. State stays consistent: pick a station in the popup, refresh new tab → same station selected, same play state. Adds a clock that updates every 30 s.
**Quick-pick stations:** the chip row surfaces the 8 most-loved SomaFM channels (Groove Salad, Drone Zone, Indie Pop Rocks!, Secret Agent, Space Station Soma, Lush, Deep Space One, Fluid). One click → playing.
**Why this feature exists:** validates [[user_browser_resident_tools]] — David has the browser open all day, every new tab is a touchpoint. Every `Cmd+T` now opens a RangerHQ-branded landing that's one click from playing radio. The Web Store reviewer is also more likely to grant a clear single-purpose override than a vague one — having a useful New Tab Page is good signal alongside the toolbar player.
**Permissions unchanged:** no new permissions or hosts added. The override is a pure UI choice; no expanded API surface.
--- ---
+6 -2
View File
@@ -1,8 +1,8 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "RangerHQ Tuner", "name": "RangerHQ Tuner",
"version": "0.1.0", "version": "0.3.0",
"description": "Lightweight indie internet radio player. Starts with SomaFM, extensible to more networks.", "description": "Lightweight indie internet radio player. Plays SomaFM in your toolbar, logs the tracks you hear, and gives you one-click search links to Spotify, YouTube, Apple Music, and Bandcamp. No telemetry.",
"author": "David Keane", "author": "David Keane",
"homepage_url": "https://davidtkeane.com/rangerhq-tuner", "homepage_url": "https://davidtkeane.com/rangerhq-tuner",
"icons": { "icons": {
@@ -23,6 +23,10 @@
"service_worker": "src/background/service-worker.js", "service_worker": "src/background/service-worker.js",
"type": "module" "type": "module"
}, },
"chrome_url_overrides": {
"newtab": "src/newtab/newtab.html"
},
"options_page": "src/options/options.html",
"permissions": ["offscreen", "storage"], "permissions": ["offscreen", "storage"],
"host_permissions": [ "host_permissions": [
"https://somafm.com/*", "https://somafm.com/*",
+24 -5
View File
@@ -1,7 +1,12 @@
// RangerHQ Tuner — Service Worker. // RangerHQ Tuner — Service Worker.
// Job: open the offscreen audio document on demand and route messages // Job: open the offscreen audio document on demand and route messages
// between the popup (UI) and the offscreen document (audio engine). // between the popup/newtab (UI) and the offscreen document (audio engine).
// We hold no state here — Chrome will kill this worker at any time. // Also acts as the storage gateway for the offscreen doc — offscreen
// docs don't reliably have chrome.storage, so they message us instead.
// We hold no in-memory state here — Chrome will kill this worker at any time.
import { TARGETS, TYPES } from '../lib/messages.js';
import { logTrack } from '../lib/history.js';
const OFFSCREEN_PATH = 'src/offscreen/offscreen.html'; const OFFSCREEN_PATH = 'src/offscreen/offscreen.html';
@@ -19,15 +24,29 @@ async function ensureOffscreen() {
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
// Only react to messages targeted at the service worker. // Only react to messages targeted at the service worker.
if (msg.target !== 'sw') return; if (msg.target !== TARGETS.SW) return;
(async () => { (async () => {
try { try {
// Storage-gateway path: write to chrome.storage on behalf of the
// offscreen document, which can't always reach it directly.
if (msg.type === TYPES.LOG_TRACK_REQUEST) {
const appended = await logTrack(msg.entry || {});
if (appended) {
chrome.runtime.sendMessage({
target: TARGETS.POPUP,
type: TYPES.TRACK_LOGGED,
}).catch(() => {}); // no UI listening is fine
}
sendResponse({ ok: true, appended });
return;
}
// Default: forward audio-control messages to the offscreen doc.
await ensureOffscreen(); await ensureOffscreen();
// Re-route the message to the offscreen document.
const response = await chrome.runtime.sendMessage({ const response = await chrome.runtime.sendMessage({
...msg, ...msg,
target: 'offscreen', target: TARGETS.OFFSCREEN,
}); });
sendResponse(response); sendResponse(response);
} catch (err) { } catch (err) {
+191
View File
@@ -0,0 +1,191 @@
// RangerHQ Tuner — History, Favourites, and 4-button search URLs.
//
// Mirrors rangerhq-radio's inc/history.php exactly (verified 2026-06-08).
// Differences from the WP plugin:
// • Chrome has one user per profile, so we use chrome.storage.local,
// not per-user usermeta.
// • All keys are namespaced under "tuner.*" so clearAll() is trivial.
//
// Entry shape (mirror WP plugin):
// { artist: string, title: string, station: string, stationId: string, at: number }
//
// Rules (mirror WP plugin):
// • Cap at historyCap entries (default 500), FIFO.
// • Dedup against the LAST entry only (sig = lower(artist|title|stationId)).
// • Skip entries where artist === "(unknown)" (SomaFM dead-air placeholder).
export const HISTORY_KEY = 'tuner.history';
export const FAVS_KEY = 'tuner.favourites';
export const CAP_KEY = 'tuner.historyCap';
export const DEFAULT_CAP = 500;
export const MIN_CAP = 50;
export const MAX_CAP = 500;
/* ---------- Safe storage accessor ----------
* chrome.storage is not guaranteed in every extension context (e.g. some
* older Chrome versions hide it from offscreen documents). Anywhere it
* isn't present, our readers return empty defaults and writers no-op
* instead of throwing. This way history.js is safe to import from any
* surface — UI pages get real storage, edge contexts degrade silently.
*/
function storage() {
if (typeof chrome === 'undefined') return null;
if (!chrome.storage || !chrome.storage.local) return null;
return chrome.storage.local;
}
/* ---------- Readers ---------- */
export async function getHistory() {
const s = storage();
if (!s) return [];
const o = await s.get(HISTORY_KEY);
return Array.isArray(o[HISTORY_KEY]) ? o[HISTORY_KEY] : [];
}
export async function getFavourites() {
const s = storage();
if (!s) return [];
const o = await s.get(FAVS_KEY);
return Array.isArray(o[FAVS_KEY]) ? o[FAVS_KEY] : [];
}
export async function getHistoryCap() {
const s = storage();
if (!s) return DEFAULT_CAP;
const o = await s.get(CAP_KEY);
const n = o[CAP_KEY];
return (typeof n === 'number' && n >= MIN_CAP && n <= MAX_CAP) ? n : DEFAULT_CAP;
}
/* ---------- Sanitisation + dedup ---------- */
function sanitizeEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
const artist = String(entry.artist || '').trim().slice(0, 200);
const title = String(entry.title || '').trim().slice(0, 200);
const station = String(entry.station || '').trim().slice(0, 100);
const stationId = String(entry.stationId || '').trim().slice(0, 100);
if (!artist || !title) return null;
if (artist.toLowerCase() === '(unknown)') return null;
return { artist, title, station, stationId, at: Date.now() };
}
export function entrySignature(e) {
return [
(e.artist || '').toLowerCase().trim(),
(e.title || '').toLowerCase().trim(),
(e.stationId || '').toLowerCase().trim(),
].join('|');
}
/* ---------- Mutations ---------- */
/** Append a track to history (deduped + capped). Returns true if appended. */
export async function logTrack(entry) {
const s = storage();
if (!s) return false;
const clean = sanitizeEntry(entry);
if (!clean) return false;
const history = await getHistory();
if (history.length > 0) {
const lastSig = entrySignature(history[history.length - 1]);
if (entrySignature(clean) === lastSig) return false;
}
history.push(clean);
const cap = await getHistoryCap();
if (history.length > cap) history.splice(0, history.length - cap);
await s.set({ [HISTORY_KEY]: history });
return true;
}
/** Toggle favourite. Returns new state: true = now favourited, false = removed. */
export async function toggleFavourite(entry) {
const s = storage();
if (!s) return false;
const clean = sanitizeEntry({ ...entry, at: 0 }); // at gets reset
if (!clean) return false;
const sig = entrySignature(clean);
const favs = await getFavourites();
const found = favs.findIndex(f => entrySignature(f) === sig);
if (found >= 0) {
favs.splice(found, 1);
await s.set({ [FAVS_KEY]: favs });
return false;
}
clean.at = Date.now();
favs.push(clean);
await s.set({ [FAVS_KEY]: favs });
return true;
}
export async function isFavourited(entry) {
const clean = sanitizeEntry({ ...entry, at: 0 });
if (!clean) return false;
const sig = entrySignature(clean);
const favs = await getFavourites();
return favs.some(f => entrySignature(f) === sig);
}
export async function clearHistory() {
const s = storage();
if (!s) return;
await s.remove(HISTORY_KEY);
}
export async function clearFavourites() {
const s = storage();
if (!s) return;
await s.remove(FAVS_KEY);
}
/** Wipe every `tuner.*` key. Used by the "Clear EVERYTHING" button. */
export async function clearAll() {
const s = storage();
if (!s) return;
const all = await s.get(null);
const keys = Object.keys(all).filter(k => k.startsWith('tuner.'));
if (keys.length) await s.remove(keys);
}
export async function setHistoryCap(n) {
const s = storage();
if (!s) return DEFAULT_CAP;
const cap = Math.max(MIN_CAP, Math.min(MAX_CAP, Math.round(Number(n) || DEFAULT_CAP)));
await s.set({ [CAP_KEY]: cap });
const history = await getHistory();
if (history.length > cap) {
await s.set({ [HISTORY_KEY]: history.slice(-cap) });
}
return cap;
}
/* ---------- 4-button search URLs ---------- */
// Mirrors WP plugin lines 126-129 verbatim. NO auth, NO API key, NO quota.
// Public search URLs that open in a new tab — user picks what to listen to.
export function searchUrls(artist, title) {
const enc = encodeURIComponent(`${artist || ''} ${title || ''}`.trim());
return {
spotify: `https://open.spotify.com/search/${enc}`,
youtube: `https://www.youtube.com/results?search_query=${enc}`,
apple: `https://music.apple.com/search?term=${enc}`,
bandcamp: `https://bandcamp.com/search?q=${enc}`,
};
}
/* ---------- Display helpers ---------- */
export function formatRelativeTime(ts) {
const diffSec = Math.floor((Date.now() - ts) / 1000);
if (diffSec < 60) return 'just now';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
if (diffSec < 604800) return `${Math.floor(diffSec / 86400)}d ago`;
return new Date(ts).toLocaleDateString();
}
+4
View File
@@ -15,5 +15,9 @@ export const TYPES = Object.freeze({
GET_STATE: 'GET_STATE', GET_STATE: 'GET_STATE',
STATE_CHANGED: 'STATE_CHANGED', STATE_CHANGED: 'STATE_CHANGED',
METADATA_UPDATED: 'METADATA_UPDATED', METADATA_UPDATED: 'METADATA_UPDATED',
LOG_TRACK_REQUEST: 'LOG_TRACK_REQUEST', // offscreen → SW: please write this entry to tuner.history
TRACK_LOGGED: 'TRACK_LOGGED', // SW → UI: a new entry was appended to tuner.history
FAVOURITE_TOGGLED: 'FAVOURITE_TOGGLED', // UI → UI: re-render any visible fav lists
STORAGE_WIPED: 'STORAGE_WIPED', // options → UI: catastrophic reset, re-init from defaults
ERROR: 'ERROR', ERROR: 'ERROR',
}); });
+587
View File
@@ -0,0 +1,587 @@
/* RangerHQ Tuner — New Tab Page.
Full-viewport landing that replaces Chrome's default new tab.
Same earthy palette as the popup, but laid out as a focal page
(player centered + quick stations + browsable list).
*/
:root {
--bg: #0f1411;
--bg-soft: #1a221c;
--bg-row: #1f2823;
--bg-row-hi: #2a3530;
--fg: #e8e4d4;
--fg-muted: #97a094;
--accent: #6dbf7a;
--accent-dim: #2a7d3e;
--cream: #f4e9b7;
--danger: #c9685b;
--radius: 8px;
--maxw: 760px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
width: 100%;
min-height: 100vh;
background: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* Subtle background watermark — the helmet, very faint */
body::before {
content: "";
position: fixed;
inset: 0;
background-image: url('../assets/img/ranger.png');
background-repeat: no-repeat;
background-position: center;
background-size: min(60vh, 600px);
opacity: 0.025;
pointer-events: none;
z-index: 0;
}
/* Everything else sits on top */
.nt-header, .nt-main, .nt-footer { position: relative; z-index: 1; }
/* ========== Header ========== */
.nt-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 28px;
border-bottom: 1px solid var(--bg-soft);
}
.nt-brand {
display: flex;
align-items: center;
gap: 10px;
}
.nt-helmet {
width: 26px;
height: 26px;
object-fit: contain;
}
.nt-brand-name {
font-size: 14px;
font-weight: 600;
letter-spacing: 0.4px;
}
.nt-clock {
text-align: right;
}
.nt-time {
font-size: 22px;
font-weight: 600;
letter-spacing: 1px;
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: baseline;
gap: 4px;
}
.nt-secs {
font-size: 13px;
font-weight: 500;
color: var(--fg-muted);
letter-spacing: 0.5px;
}
.nt-date {
font-size: 11px;
color: var(--fg-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 2px;
}
/* ========== Main ========== */
.nt-main {
max-width: var(--maxw);
margin: 0 auto;
padding: 40px 28px 28px;
display: flex;
flex-direction: column;
gap: 28px;
}
/* Now playing — large + centered */
.nt-now {
text-align: center;
padding: 24px 0 8px;
}
.nt-station {
font-size: 26px;
font-weight: 600;
letter-spacing: 0.2px;
margin-bottom: 6px;
}
.nt-track {
font-size: 15px;
color: var(--fg-muted);
min-height: 1.4em;
margin-bottom: 16px;
}
.nt-state-pill {
display: inline-block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
padding: 4px 12px;
border-radius: 999px;
background: var(--bg-row);
color: var(--fg-muted);
}
.nt-state-pill[data-state="playing"] {
background: var(--accent);
color: var(--bg);
}
.nt-state-pill[data-state="buffering"] {
background: var(--cream);
color: var(--bg);
}
.nt-state-pill[data-state="error"] {
background: var(--danger);
color: var(--cream);
}
/* Controls — generous click targets */
.nt-controls {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
padding: 10px 0;
}
.nt-btn {
font-size: 15px;
font-weight: 500;
padding: 10px 22px;
border: 1px solid var(--accent-dim);
border-radius: var(--radius);
background: var(--bg-row);
color: var(--fg);
cursor: pointer;
}
.nt-btn:hover:not(:disabled) {
background: var(--bg-row-hi);
}
.nt-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.nt-btn--primary {
background: var(--accent-dim);
min-width: 120px;
}
.nt-btn--primary[aria-pressed="true"] {
background: var(--accent);
color: var(--bg);
}
.nt-vol-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 240px;
}
.nt-vol-label {
font-size: 11px;
color: var(--fg-muted);
letter-spacing: 1px;
text-transform: uppercase;
}
#nt-volume {
flex: 1;
accent-color: var(--accent);
}
/* Quick stations — chip row */
.nt-quick {
border-top: 1px solid var(--bg-soft);
padding-top: 18px;
}
.nt-quick-label {
font-size: 11px;
color: var(--fg-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
.nt-quick-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.nt-quick-chip {
font-size: 12px;
padding: 6px 12px;
border: 1px solid var(--bg-row-hi);
border-radius: 999px;
background: var(--bg-row);
color: var(--fg);
cursor: pointer;
white-space: nowrap;
}
.nt-quick-chip:hover {
background: var(--bg-row-hi);
border-color: var(--accent-dim);
}
.nt-quick-chip.is-active {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
}
.nt-quick-empty {
font-size: 12px;
color: var(--fg-muted);
}
/* ========== Tabs (Stations / History / Favourites) ========== */
.nt-tabs {
border-top: 1px solid var(--bg-soft);
padding-top: 18px;
}
.nt-tab-bar {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 12px;
border-bottom: 1px solid var(--bg-soft);
}
.nt-tab {
font-size: 12px;
font-weight: 500;
padding: 8px 14px;
background: transparent;
color: var(--fg-muted);
border: 0;
border-bottom: 2px solid transparent;
cursor: pointer;
font-family: inherit;
text-decoration: none;
}
.nt-tab:hover { color: var(--fg); }
.nt-tab.is-active {
color: var(--fg);
border-bottom-color: var(--accent);
}
.nt-tab--link {
margin-left: auto;
font-size: 16px;
padding: 6px 10px;
line-height: 1;
border-bottom: none;
}
.nt-tab--link:hover { color: var(--accent); }
.nt-tab-pane[hidden] { display: none; }
.nt-pane-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.nt-pane-count {
font-size: 11px;
color: var(--fg-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.nt-pane-action {
font-size: 11px;
padding: 4px 10px;
background: var(--bg-row);
color: var(--fg-muted);
border: 1px solid var(--bg-row-hi);
border-radius: 4px;
cursor: pointer;
}
.nt-pane-action:hover {
color: var(--danger);
border-color: var(--danger);
}
/* ========== Track list (History + Favourites) ========== */
.nt-track-list {
margin: 0;
padding: 0;
list-style: none;
max-height: 380px;
overflow-y: auto;
border: 1px solid var(--bg-soft);
border-radius: var(--radius);
}
.nt-track-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px 14px;
padding: 10px 14px;
border-bottom: 1px solid var(--bg-soft);
}
.nt-track-row:last-child { border-bottom: none; }
.nt-track-row:hover { background: var(--bg-row); }
.nt-track-main { min-width: 0; }
.nt-track-title {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nt-track-artist {
font-size: 12px;
color: var(--accent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nt-track-meta {
font-size: 10px;
color: var(--fg-muted);
letter-spacing: 0.3px;
margin-top: 2px;
}
.nt-track-controls {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.nt-fav-btn {
background: transparent;
border: 1px solid var(--bg-row-hi);
border-radius: 999px;
width: 26px;
height: 26px;
cursor: pointer;
color: var(--fg-muted);
font-size: 14px;
line-height: 1;
padding: 0;
}
.nt-fav-btn:hover { color: var(--cream); border-color: var(--cream); }
.nt-fav-btn[aria-pressed="true"] {
color: var(--cream);
border-color: var(--cream);
background: rgba(244, 233, 183, 0.08);
}
.nt-search-row {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 5px;
padding-top: 4px;
}
.nt-search-link {
font-size: 10px;
font-weight: 600;
padding: 3px 8px;
border-radius: 3px;
text-decoration: none;
color: var(--fg);
letter-spacing: 0.3px;
border: 1px solid transparent;
text-transform: uppercase;
}
/* Service-specific brand-leaning colours — kept muted so they don't shout */
.nt-search-link--spotify { background: #1ed76015; color: #1ed760; border-color: #1ed76040; }
.nt-search-link--youtube { background: #ff000015; color: #ff5a5a; border-color: #ff5a5a40; }
.nt-search-link--apple { background: #fa57c115; color: #fa57c1; border-color: #fa57c140; }
.nt-search-link--bandcamp { background: #629aa915; color: #6dbdd0; border-color: #6dbdd040; }
.nt-search-link:hover { filter: brightness(1.3); }
.nt-track-empty {
padding: 28px 16px;
text-align: center;
color: var(--fg-muted);
font-size: 12px;
}
/* (Legacy alias — older code referenced .nt-browse for the bottom section.
Keep here in case any styles bleed through during transition.) */
.nt-browse {
border-top: 1px solid var(--bg-soft);
padding-top: 18px;
}
.nt-search-wrap {
display: block;
margin-bottom: 10px;
}
#nt-search {
width: 100%;
padding: 10px 12px;
font-size: 13px;
background: var(--bg-row);
color: var(--fg);
border: 1px solid var(--bg-row-hi);
border-radius: var(--radius);
}
#nt-search:focus {
outline: none;
border-color: var(--accent);
}
.nt-station-list {
margin: 0;
padding: 0;
list-style: none;
max-height: 320px;
overflow-y: auto;
border: 1px solid var(--bg-soft);
border-radius: var(--radius);
}
.nt-station-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--bg-soft);
cursor: pointer;
}
.nt-station-row:last-child { border-bottom: none; }
.nt-station-row:hover {
background: var(--bg-row);
}
.nt-station-row.is-active {
background: var(--bg-row-hi);
border-left: 3px solid var(--accent);
padding-left: 11px;
}
.nt-station-art {
width: 32px;
height: 32px;
border-radius: 4px;
background: var(--bg-soft);
flex-shrink: 0;
object-fit: cover;
}
.nt-station-meta {
flex: 1;
min-width: 0;
}
.nt-station-name {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nt-station-genre {
font-size: 11px;
color: var(--fg-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ========== Footer ========== */
.nt-footer {
max-width: var(--maxw);
margin: 0 auto;
padding: 20px 28px 28px;
text-align: center;
font-size: 11px;
color: var(--fg-muted);
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.nt-footer a {
color: var(--fg-muted);
text-decoration: none;
border-bottom: 1px dotted var(--bg-row-hi);
}
.nt-footer a:hover {
color: var(--accent);
border-bottom-color: var(--accent);
}
.nt-dot { opacity: 0.5; }
/* Scrollbar — subtle */
.nt-station-list::-webkit-scrollbar {
width: 8px;
}
.nt-station-list::-webkit-scrollbar-thumb {
background: var(--bg-row-hi);
border-radius: 4px;
}
.nt-station-list::-webkit-scrollbar-track {
background: transparent;
}
/* Tighter spacing on smaller windows */
@media (max-height: 700px) {
.nt-main { padding-top: 24px; gap: 18px; }
.nt-now { padding: 12px 0 4px; }
.nt-station { font-size: 22px; }
.nt-station-list { max-height: 220px; }
}
+91
View File
@@ -0,0 +1,91 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>RangerHQ Tuner — New Tab</title>
<link rel="stylesheet" href="newtab.css">
<link rel="icon" href="../assets/icons/icon-48.png">
</head>
<body>
<header class="nt-header">
<div class="nt-brand">
<img src="../assets/img/ranger.png" alt="" class="nt-helmet">
<span class="nt-brand-name">RangerHQ Tuner</span>
</div>
<div class="nt-clock" aria-live="off">
<div class="nt-time" id="nt-time">
<span id="nt-hm">--:--</span><span class="nt-secs" id="nt-secs">--</span>
</div>
<div class="nt-date" id="nt-date"></div>
</div>
</header>
<main class="nt-main">
<section class="nt-now" aria-label="Now playing">
<div class="nt-station" id="nt-station">Pick a station to begin</div>
<div class="nt-track" id="nt-track"></div>
<span class="nt-state-pill" id="nt-state" aria-live="polite">idle</span>
</section>
<section class="nt-controls" aria-label="Playback controls">
<button id="nt-play" class="nt-btn nt-btn--primary" aria-pressed="false" disabled>▶ Play</button>
<label class="nt-vol-wrap">
<span class="nt-vol-label">Vol</span>
<input type="range" id="nt-volume" min="0" max="100" value="70" aria-label="Volume">
</label>
</section>
<section class="nt-quick" aria-label="Quick stations">
<div class="nt-quick-label">Quick stations</div>
<div class="nt-quick-list" id="nt-quick-list">
<span class="nt-quick-empty">Loading…</span>
</div>
</section>
<section class="nt-tabs" aria-label="Browse">
<div class="nt-tab-bar" role="tablist">
<button class="nt-tab is-active" role="tab" aria-selected="true" data-tab="stations">Stations</button>
<button class="nt-tab" role="tab" aria-selected="false" data-tab="history">History</button>
<button class="nt-tab" role="tab" aria-selected="false" data-tab="favourites">Favourites</button>
<a class="nt-tab nt-tab--link" href="#" id="nt-open-options" title="Open settings"></a>
</div>
<div class="nt-tab-pane" id="nt-pane-stations" role="tabpanel">
<label class="nt-search-wrap">
<input type="search" id="nt-search" placeholder="Search all stations…" aria-label="Search stations">
</label>
<ul id="nt-station-list" class="nt-station-list" role="listbox"></ul>
</div>
<div class="nt-tab-pane" id="nt-pane-history" role="tabpanel" hidden>
<div class="nt-pane-toolbar">
<span class="nt-pane-count" id="nt-history-count"></span>
<button class="nt-pane-action" id="nt-history-clear" type="button">Clear history</button>
</div>
<ul id="nt-history-list" class="nt-track-list" role="list"></ul>
</div>
<div class="nt-tab-pane" id="nt-pane-favourites" role="tabpanel" hidden>
<div class="nt-pane-toolbar">
<span class="nt-pane-count" id="nt-favs-count"></span>
<button class="nt-pane-action" id="nt-favs-clear" type="button">Clear favourites</button>
</div>
<ul id="nt-favs-list" class="nt-track-list" role="list"></ul>
</div>
</section>
</main>
<footer class="nt-footer">
<a href="https://davidtkeane.com" target="_blank" rel="noopener">davidtkeane.com</a>
<span class="nt-dot">·</span>
<a href="https://git.davidtkeane.com/ranger/rangerhq-tuner" target="_blank" rel="noopener">Gitea</a>
<span class="nt-dot">·</span>
<span>SomaFM · indie radio · no telemetry</span>
</footer>
<script type="module" src="newtab.js"></script>
</body>
</html>
+515
View File
@@ -0,0 +1,515 @@
// RangerHQ Tuner — New Tab Page controller.
// Replaces Chrome's default new tab with the player as a focal landing.
// Same underlying mechanics as the popup (sources/index, messages to SW,
// chrome.storage state) — but laid out for a full viewport with a
// quick-station chip row and a searchable browse list.
import { TARGETS, TYPES } from '../lib/messages.js';
import { listAllStations, getSource } from '../sources/index.js';
import {
getHistory, getFavourites,
toggleFavourite, isFavourited,
clearHistory, clearFavourites,
searchUrls, formatRelativeTime,
entrySignature,
} from '../lib/history.js';
const els = {
// Clock
hm: document.getElementById('nt-hm'),
secs: document.getElementById('nt-secs'),
date: document.getElementById('nt-date'),
// Now playing
station: document.getElementById('nt-station'),
track: document.getElementById('nt-track'),
state: document.getElementById('nt-state'),
// Controls
play: document.getElementById('nt-play'),
volume: document.getElementById('nt-volume'),
// Stations
quickList: document.getElementById('nt-quick-list'),
search: document.getElementById('nt-search'),
list: document.getElementById('nt-station-list'),
// Tabs + panes
tabBar: document.querySelector('.nt-tab-bar'),
paneStations: document.getElementById('nt-pane-stations'),
paneHistory: document.getElementById('nt-pane-history'),
paneFavs: document.getElementById('nt-pane-favourites'),
historyList: document.getElementById('nt-history-list'),
favsList: document.getElementById('nt-favs-list'),
historyCount: document.getElementById('nt-history-count'),
favsCount: document.getElementById('nt-favs-count'),
historyClear: document.getElementById('nt-history-clear'),
favsClear: document.getElementById('nt-favs-clear'),
openOptions: document.getElementById('nt-open-options'),
};
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;
// Quick-pick stations. These are the SomaFM ids most people start with.
// If the catalogue is missing any (rare), they're just dropped silently.
const QUICK_IDS = [
'groovesalad',
'dronezone',
'indiepop',
'secretagent',
'spacestation',
'lush',
'deepspaceone',
'fluid',
];
let stations = [];
let currentStation = null;
let playing = false;
let lastQuery = '';
let clockTimer = null;
init().catch(err => {
console.error('Newtab init failed:', err);
setState('error');
els.track.textContent = `Init failed: ${err.message}`;
});
async function init() {
// Clock first — visible even before stations load.
tickClock();
clockTimer = setInterval(tickClock, 1000); // 1 Hz — live seconds
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();
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(),
});
}
const currentId = stored[STORAGE_KEYS.currentId];
currentStation = stations.find(s => s.id === currentId) || null;
renderNow();
renderQuick();
renderList();
els.play.addEventListener('click', onPlayToggle);
els.volume.addEventListener('input', onVolume);
els.search.addEventListener('input', () => {
lastQuery = els.search.value.trim().toLowerCase();
renderList();
});
// Tabs
els.tabBar.addEventListener('click', (e) => {
const tab = e.target.closest('.nt-tab');
if (!tab) return;
if (tab.id === 'nt-open-options') {
e.preventDefault();
chrome.runtime.openOptionsPage();
return;
}
selectTab(tab.dataset.tab);
});
els.historyClear.addEventListener('click', async () => {
if (!confirm('Clear all track history? Favourites will be preserved.')) return;
await clearHistory();
await renderHistory();
});
els.favsClear.addEventListener('click', async () => {
if (!confirm('Clear all favourites?')) return;
await clearFavourites();
await renderFavourites();
await renderHistory(); // re-render history so star indicators update
});
// First render of track panes
await renderHistory();
await renderFavourites();
// Deep-link via URL hash — popup's "History" / "Favourites" buttons
// open us with #history or #favourites. Respect that on load AND on
// hash change (e.g. user changes it manually).
applyHashTab();
window.addEventListener('hashchange', applyHashTab);
chrome.runtime.onMessage.addListener((msg) => {
if (msg.target !== TARGETS.POPUP) return; // SW broadcasts to "popup" as the UI bucket
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.track.textContent = `Stream error (code ${msg.code ?? '?'})`;
} else if (msg.type === TYPES.METADATA_UPDATED) {
if (msg.nowPlaying) els.track.textContent = formatNowPlaying(msg.nowPlaying);
} else if (msg.type === TYPES.TRACK_LOGGED) {
renderHistory();
} else if (msg.type === TYPES.STORAGE_WIPED) {
// Catastrophic reset — re-read everything from scratch.
renderHistory();
renderFavourites();
renderQuick();
renderList();
}
});
// Cross-surface sync — if the popup (or another newtab) changes the
// current station, re-render here live without a manual reload.
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'local') return;
if (changes[STORAGE_KEYS.currentId] && stations.length) {
const newId = changes[STORAGE_KEYS.currentId].newValue;
currentStation = stations.find(s => s.id === newId) || null;
els.play.disabled = !currentStation;
renderNow();
renderQuick();
renderList();
}
if (changes[STORAGE_KEYS.isPlaying]) {
playing = !!changes[STORAGE_KEYS.isPlaying].newValue;
reflectPlayButton();
}
if (changes['tuner.history']) renderHistory();
if (changes['tuner.favourites']) {
renderFavourites();
renderHistory();
}
});
els.play.disabled = !currentStation;
}
/* ---------- Clock ---------- */
function tickClock() {
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
els.hm.textContent = `${hh}:${mm}`;
els.secs.textContent = ss;
// Date only changes once a day; rendering the same string per tick is
// free (no DOM diff cost) so we don't bother to short-circuit it.
els.date.textContent = now.toLocaleDateString(undefined, {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
});
}
/* ---------- Rendering ---------- */
function renderNow() {
if (currentStation) {
els.station.textContent = currentStation.name;
els.track.textContent = playing ? 'Now playing — fetching metadata…' : 'Ready to play';
} else {
els.station.textContent = 'Pick a station to begin';
els.track.textContent = '—';
}
}
function renderQuick() {
els.quickList.innerHTML = '';
if (!stations.length) {
const empty = document.createElement('span');
empty.className = 'nt-quick-empty';
empty.textContent = 'Loading…';
els.quickList.appendChild(empty);
return;
}
for (const shortId of QUICK_IDS) {
const fullId = `somafm:${shortId}`;
const s = stations.find(st => st.id === fullId);
if (!s) continue;
const chip = document.createElement('button');
chip.className = 'nt-quick-chip';
if (currentStation && currentStation.id === s.id) chip.classList.add('is-active');
chip.type = 'button';
chip.textContent = s.name;
chip.title = s.description || s.genre || s.name;
chip.addEventListener('click', () => onPickStation(s));
els.quickList.appendChild(chip);
}
}
function renderList() {
if (!stations.length) {
els.list.innerHTML = '<li class="nt-quick-empty" style="padding:14px;">Loading stations…</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;
els.list.innerHTML = '';
if (!filtered.length) {
const li = document.createElement('li');
li.className = 'nt-quick-empty';
li.style.padding = '14px';
li.textContent = 'No matches.';
els.list.appendChild(li);
return;
}
for (const s of filtered) {
const li = document.createElement('li');
li.className = 'nt-station-row';
if (currentStation && currentStation.id === s.id) li.classList.add('is-active');
li.setAttribute('role', 'option');
const img = document.createElement('img');
img.className = 'nt-station-art';
img.alt = '';
img.src = s.artworkUrl || '';
img.onerror = () => { img.style.visibility = 'hidden'; };
const meta = document.createElement('div');
meta.className = 'nt-station-meta';
const name = document.createElement('div');
name.className = 'nt-station-name';
name.textContent = s.name;
const genre = document.createElement('div');
genre.className = 'nt-station-genre';
genre.textContent = s.genre || s.description.slice(0, 80);
meta.appendChild(name);
meta.appendChild(genre);
li.appendChild(img);
li.appendChild(meta);
li.addEventListener('click', () => onPickStation(s));
els.list.appendChild(li);
}
}
/* ---------- Actions ---------- */
async function onPickStation(station) {
currentStation = station;
els.play.disabled = false;
await chrome.storage.local.set({ [STORAGE_KEYS.currentId]: station.id });
renderNow();
renderQuick();
renderList();
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);
await sendToSW({ type: TYPES.SET_VOLUME, volume: Number(els.volume.value) / 100 });
const stationLite = {
id: currentStation.id,
sourceId: currentStation.sourceId,
name: currentStation.name,
};
const resp = await sendToSW({ type: TYPES.PLAY, streamUrl, station: stationLite });
if (!resp?.ok) throw new Error(resp?.error || 'PLAY failed');
// Offscreen will broadcast METADATA_UPDATED within ~1s.
} catch (err) {
console.error(err);
setState('error');
els.track.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 });
}
/* ---------- Helpers ---------- */
function reflectPlayButton() {
els.play.setAttribute('aria-pressed', playing ? 'true' : 'false');
els.play.textContent = playing ? '❚❚ Pause' : '▶ Play';
}
function setState(state) {
els.state.dataset.state = state;
els.state.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 });
}
/* ---------- Tabs ---------- */
function selectTab(name) {
const map = {
stations: els.paneStations,
history: els.paneHistory,
favourites: els.paneFavs,
};
for (const [k, pane] of Object.entries(map)) {
const active = (k === name);
pane.hidden = !active;
const tabBtn = els.tabBar.querySelector(`[data-tab="${k}"]`);
if (tabBtn) {
tabBtn.classList.toggle('is-active', active);
tabBtn.setAttribute('aria-selected', active ? 'true' : 'false');
}
}
}
function applyHashTab() {
const hash = (location.hash || '').replace(/^#/, '').toLowerCase();
if (hash === 'history' || hash === 'favourites' || hash === 'stations') {
selectTab(hash);
}
}
/* ---------- History + Favourites rendering ---------- */
async function renderHistory() {
const entries = await getHistory();
els.historyCount.textContent = `${entries.length} track${entries.length === 1 ? '' : 's'}`;
if (!entries.length) {
els.historyList.innerHTML = '<li class="nt-track-empty">No tracks logged yet. Play a station for a minute and they\'ll appear here.</li>';
return;
}
// Newest first
const favs = await getFavourites();
const favSigs = new Set(favs.map(entrySignature));
els.historyList.innerHTML = '';
for (let i = entries.length - 1; i >= 0; i--) {
els.historyList.appendChild(renderTrackRow(entries[i], favSigs));
}
}
async function renderFavourites() {
const entries = await getFavourites();
els.favsCount.textContent = `${entries.length} favourite${entries.length === 1 ? '' : 's'}`;
if (!entries.length) {
els.favsList.innerHTML = '<li class="nt-track-empty">No favourites yet. Tap the star next to a history track to add it.</li>';
return;
}
const favSigs = new Set(entries.map(entrySignature));
els.favsList.innerHTML = '';
for (let i = entries.length - 1; i >= 0; i--) {
els.favsList.appendChild(renderTrackRow(entries[i], favSigs));
}
}
function renderTrackRow(entry, favSigs) {
const li = document.createElement('li');
li.className = 'nt-track-row';
// Main column — title (big), artist (accent), meta line
const main = document.createElement('div');
main.className = 'nt-track-main';
const title = document.createElement('div');
title.className = 'nt-track-title';
title.textContent = entry.title;
title.title = entry.title;
const artist = document.createElement('div');
artist.className = 'nt-track-artist';
artist.textContent = entry.artist;
artist.title = entry.artist;
const meta = document.createElement('div');
meta.className = 'nt-track-meta';
const station = entry.station || '—';
meta.textContent = `${station} · ${formatRelativeTime(entry.at)}`;
main.append(title, artist, meta);
// Right column — star + (future) overflow menu
const controls = document.createElement('div');
controls.className = 'nt-track-controls';
const fav = document.createElement('button');
fav.type = 'button';
fav.className = 'nt-fav-btn';
const isFav = favSigs.has(entrySignature(entry));
fav.setAttribute('aria-pressed', isFav ? 'true' : 'false');
fav.setAttribute('aria-label', isFav ? 'Remove from favourites' : 'Add to favourites');
fav.textContent = isFav ? '★' : '☆';
fav.addEventListener('click', async () => {
const nowFav = await toggleFavourite(entry);
fav.setAttribute('aria-pressed', nowFav ? 'true' : 'false');
fav.textContent = nowFav ? '★' : '☆';
fav.setAttribute('aria-label', nowFav ? 'Remove from favourites' : 'Add to favourites');
// Re-render favs tab so it reflects the change.
renderFavourites();
});
controls.appendChild(fav);
// Search-link row — spans full width below
const search = document.createElement('div');
search.className = 'nt-search-row';
const urls = searchUrls(entry.artist, entry.title);
const linkOf = (svc, label) => {
const a = document.createElement('a');
a.className = `nt-search-link nt-search-link--${svc}`;
a.href = urls[svc];
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = label;
a.title = `Search ${label} for "${entry.artist}${entry.title}"`;
return a;
};
search.append(
linkOf('spotify', 'Spotify'),
linkOf('youtube', 'YouTube'),
linkOf('apple', 'Apple'),
linkOf('bandcamp', 'Bandcamp'),
);
li.append(main, controls, search);
return li;
}
+96 -16
View File
@@ -1,31 +1,55 @@
// RangerHQ Tuner — Offscreen audio host. // RangerHQ Tuner — Offscreen audio host.
// Owns the <audio> element. MV3 service workers can't host audio // Owns the <audio> element and the 25-second metadata polling loop.
// (they get killed when idle), so all live media lives here. // 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'); 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) => { chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.target !== 'offscreen') return; if (msg.target !== TARGETS.OFFSCREEN) return;
switch (msg.type) { switch (msg.type) {
case 'PLAY': case TYPES.PLAY:
currentStation = msg.station || null;
audio.src = msg.streamUrl; 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() audio.play()
.then(() => sendResponse({ ok: true })) .then(() => sendResponse({ ok: true }))
.catch(err => sendResponse({ ok: false, error: err.message })); .catch(err => {
return true; // keep channel open for async sendResponse sendResponse({ ok: false, error: err.message });
stopPolling();
});
return true; // async sendResponse
case 'PAUSE': case TYPES.PAUSE:
audio.pause(); audio.pause();
stopPolling();
sendResponse({ ok: true }); sendResponse({ ok: true });
break; break;
case 'SET_VOLUME': case TYPES.SET_VOLUME:
audio.volume = Math.max(0, Math.min(1, msg.volume)); audio.volume = Math.max(0, Math.min(1, msg.volume));
sendResponse({ ok: true }); sendResponse({ ok: true });
break; break;
case 'GET_STATE': case TYPES.GET_STATE:
sendResponse({ sendResponse({
ok: true, ok: true,
paused: audio.paused, paused: audio.paused,
@@ -36,13 +60,69 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
} }
}); });
// Broadcast state changes to anyone listening (popup, SW). /* ---------- Audio events ---------- */
// If popup is closed, the send fails silently — that's fine.
audio.addEventListener('playing', () => broadcast({ type: 'STATE_CHANGED', state: 'playing' })); audio.addEventListener('playing', () => broadcast({ type: TYPES.STATE_CHANGED, state: 'playing' }));
audio.addEventListener('pause', () => broadcast({ type: 'STATE_CHANGED', state: 'paused' })); audio.addEventListener('pause', () => broadcast({ type: TYPES.STATE_CHANGED, state: 'paused' }));
audio.addEventListener('waiting', () => broadcast({ type: 'STATE_CHANGED', state: 'buffering' })); audio.addEventListener('waiting', () => broadcast({ type: TYPES.STATE_CHANGED, state: 'buffering' }));
audio.addEventListener('error', () => broadcast({ type: 'ERROR', code: audio.error?.code })); audio.addEventListener('error', () => {
stopPolling();
broadcast({ type: TYPES.ERROR, code: audio.error?.code });
});
function broadcast(payload) { 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 */ });
} }
+262
View File
@@ -0,0 +1,262 @@
/* RangerHQ Tuner — Options page.
Same earthy palette as popup + newtab.
*/
:root {
--bg: #0f1411;
--bg-soft: #1a221c;
--bg-row: #1f2823;
--bg-row-hi: #2a3530;
--fg: #e8e4d4;
--fg-muted: #97a094;
--accent: #6dbf7a;
--accent-dim: #2a7d3e;
--cream: #f4e9b7;
--danger: #c9685b;
--radius: 6px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
min-height: 100vh;
}
.opt-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 28px;
background: var(--bg-soft);
border-bottom: 1px solid #000;
}
.opt-brand {
display: flex;
align-items: center;
gap: 12px;
}
.opt-helmet {
width: 28px;
height: 28px;
object-fit: contain;
}
.opt-brand h1 {
margin: 0;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.3px;
}
.opt-version {
font-size: 11px;
color: var(--fg-muted);
letter-spacing: 0.5px;
padding: 3px 10px;
background: var(--bg-row);
border-radius: 999px;
}
.opt-main {
max-width: 680px;
margin: 0 auto;
padding: 28px;
display: flex;
flex-direction: column;
gap: 18px;
}
.opt-card {
background: var(--bg-soft);
border: 1px solid var(--bg-row);
border-radius: var(--radius);
padding: 20px 22px;
}
.opt-card h2 {
margin: 0 0 14px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--accent);
}
.opt-note {
margin: 0 0 16px;
font-size: 12px;
color: var(--fg-muted);
line-height: 1.5;
}
.opt-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-bottom: 18px;
}
.opt-stat {
padding: 10px 14px;
background: var(--bg-row);
border-radius: 4px;
}
.opt-stat-label {
font-size: 10px;
color: var(--fg-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
.opt-stat-value {
font-size: 14px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.opt-row {
margin: 16px 0;
}
.opt-row label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 6px;
}
.opt-row--inline {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--bg-row);
margin: 0;
}
.opt-row--inline:last-child { border-bottom: none; }
.opt-row--inline label { margin-bottom: 0; }
.opt-help {
display: block;
font-size: 11px;
font-weight: 400;
color: var(--fg-muted);
margin-top: 2px;
}
.opt-slider-wrap {
display: flex;
align-items: center;
gap: 14px;
}
#opt-cap {
flex: 1;
accent-color: var(--accent);
}
.opt-slider-value {
font-variant-numeric: tabular-nums;
font-weight: 500;
min-width: 3em;
text-align: right;
}
.opt-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--bg-row);
}
.opt-btn {
font-family: inherit;
font-size: 12px;
font-weight: 500;
padding: 8px 16px;
background: var(--bg-row);
color: var(--fg);
border: 1px solid var(--bg-row-hi);
border-radius: var(--radius);
cursor: pointer;
}
.opt-btn:hover {
border-color: var(--accent);
}
.opt-btn--danger {
border-color: var(--danger);
color: var(--danger);
}
.opt-btn--danger:hover {
background: var(--danger);
color: var(--cream);
}
.opt-about {
margin: 0 0 14px;
font-size: 13px;
color: var(--fg-muted);
line-height: 1.6;
}
.opt-about a, .opt-links a {
color: var(--accent);
text-decoration: none;
border-bottom: 1px dotted transparent;
}
.opt-about a:hover, .opt-links a:hover {
border-bottom-color: var(--accent);
}
.opt-links {
margin: 0;
padding: 0;
list-style: none;
font-size: 12px;
color: var(--fg-muted);
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
.opt-links strong {
color: var(--fg);
font-weight: 500;
}
.opt-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--accent);
color: var(--bg);
padding: 10px 18px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 500;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.opt-toast[data-tone="error"] {
background: var(--danger);
color: var(--cream);
}
+95
View File
@@ -0,0 +1,95 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>RangerHQ Tuner — Options</title>
<link rel="stylesheet" href="options.css">
<link rel="icon" href="../assets/icons/icon-48.png">
</head>
<body>
<header class="opt-header">
<div class="opt-brand">
<img src="../assets/img/ranger.png" alt="" class="opt-helmet">
<h1>RangerHQ Tuner — Options</h1>
</div>
<span class="opt-version" id="opt-version">v—</span>
</header>
<main class="opt-main">
<section class="opt-card">
<h2>Local data</h2>
<p class="opt-note">Everything RangerHQ Tuner stores lives in your browser only. No data is sent anywhere.</p>
<div class="opt-stats">
<div class="opt-stat">
<div class="opt-stat-label">History</div>
<div class="opt-stat-value" id="opt-stat-history">— tracks</div>
</div>
<div class="opt-stat">
<div class="opt-stat-label">Favourites</div>
<div class="opt-stat-value" id="opt-stat-favs">— tracks</div>
</div>
<div class="opt-stat">
<div class="opt-stat-label">Stations cached</div>
<div class="opt-stat-value" id="opt-stat-cache">— stations</div>
</div>
<div class="opt-stat">
<div class="opt-stat-label">Total local data</div>
<div class="opt-stat-value" id="opt-stat-bytes"></div>
</div>
</div>
<div class="opt-row">
<label for="opt-cap">
History cap
<span class="opt-help">How many recent tracks to keep before the oldest are dropped.</span>
</label>
<div class="opt-slider-wrap">
<input type="range" id="opt-cap" min="50" max="500" step="50" value="500">
<span class="opt-slider-value" id="opt-cap-value">500</span>
</div>
</div>
<div class="opt-actions">
<button id="opt-clear-history" class="opt-btn">Clear history</button>
<button id="opt-clear-favs" class="opt-btn">Clear favourites</button>
<button id="opt-clear-all" class="opt-btn opt-btn--danger">Clear EVERYTHING</button>
</div>
</section>
<section class="opt-card">
<h2>Playback</h2>
<div class="opt-row opt-row--inline">
<label>Current volume</label>
<span id="opt-volume-display" class="opt-stat-value"></span>
</div>
<div class="opt-row opt-row--inline">
<label>Last station</label>
<span id="opt-last-station" class="opt-stat-value"></span>
</div>
</section>
<section class="opt-card">
<h2>About</h2>
<p class="opt-about">
RangerHQ Tuner is a lightweight indie internet radio player, sibling to
<a href="https://wordpress.org/plugins/rangerhq-radio/" target="_blank" rel="noopener">RangerHQ Radio</a>
on WordPress.org. No telemetry, no third-party JavaScript, GPL v2 or later.
</p>
<ul class="opt-links">
<li><strong>Repo:</strong> <a href="https://git.davidtkeane.com/ranger/rangerhq-tuner" target="_blank" rel="noopener">git.davidtkeane.com/ranger/rangerhq-tuner</a></li>
<li><strong>Maker:</strong> <a href="https://davidtkeane.com" target="_blank" rel="noopener">davidtkeane.com</a></li>
<li><strong>Stations:</strong> <a href="https://somafm.com" target="_blank" rel="noopener">SomaFM</a> — listener-supported indie radio</li>
<li><strong>Licence:</strong> GPL v2 or later</li>
</ul>
</section>
<p class="opt-toast" id="opt-toast" hidden></p>
</main>
<script type="module" src="options.js"></script>
</body>
</html>
+152
View File
@@ -0,0 +1,152 @@
// RangerHQ Tuner — Options page controller.
// Manages the user's local data: history cap, clear actions, stats display.
// Broadcasts STORAGE_WIPED after destructive actions so the popup/newtab
// rerender immediately.
import { TARGETS, TYPES } from '../lib/messages.js';
import {
getHistory, getFavourites,
getHistoryCap, setHistoryCap,
clearHistory, clearFavourites, clearAll,
} from '../lib/history.js';
const STORAGE_KEYS = {
stations: 'tuner.stationsCache',
currentId: 'tuner.currentStationId',
volume: 'tuner.volume',
};
const els = {
version: document.getElementById('opt-version'),
statHistory: document.getElementById('opt-stat-history'),
statFavs: document.getElementById('opt-stat-favs'),
statCache: document.getElementById('opt-stat-cache'),
statBytes: document.getElementById('opt-stat-bytes'),
capSlider: document.getElementById('opt-cap'),
capValue: document.getElementById('opt-cap-value'),
clearHistory: document.getElementById('opt-clear-history'),
clearFavs: document.getElementById('opt-clear-favs'),
clearAll: document.getElementById('opt-clear-all'),
volumeDisplay: document.getElementById('opt-volume-display'),
lastStation: document.getElementById('opt-last-station'),
toast: document.getElementById('opt-toast'),
};
init().catch(err => {
console.error('Options init failed:', err);
toast(`Init failed: ${err.message}`, 'error');
});
async function init() {
els.version.textContent = `v${chrome.runtime.getManifest().version}`;
await refreshStats();
await refreshPlayback();
const cap = await getHistoryCap();
els.capSlider.value = String(cap);
els.capValue.textContent = String(cap);
els.capSlider.addEventListener('input', () => {
els.capValue.textContent = els.capSlider.value;
});
els.capSlider.addEventListener('change', async () => {
const newCap = await setHistoryCap(Number(els.capSlider.value));
els.capValue.textContent = String(newCap);
toast(`History cap set to ${newCap}`);
await refreshStats();
});
els.clearHistory.addEventListener('click', async () => {
if (!confirm('Clear all track history? Favourites will be preserved.')) return;
await clearHistory();
await refreshStats();
notifyUis();
toast('History cleared');
});
els.clearFavs.addEventListener('click', async () => {
if (!confirm('Clear all favourites?')) return;
await clearFavourites();
await refreshStats();
notifyUis();
toast('Favourites cleared');
});
els.clearAll.addEventListener('click', async () => {
if (!confirm('Wipe EVERYTHING — history, favourites, station cache, volume, last station? This cannot be undone.')) return;
await clearAll();
await refreshStats();
await refreshPlayback();
els.capSlider.value = '500';
els.capValue.textContent = '500';
notifyUis();
toast('All local data wiped', 'danger');
});
// Refresh stats live if storage changes from another surface
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local') return;
if (Object.keys(changes).some(k => k.startsWith('tuner.'))) {
refreshStats();
refreshPlayback();
}
});
}
async function refreshStats() {
const [history, favs, all] = await Promise.all([
getHistory(),
getFavourites(),
chrome.storage.local.get(null),
]);
const cacheArr = Array.isArray(all[STORAGE_KEYS.stations]) ? all[STORAGE_KEYS.stations] : [];
// Estimate "tuner.*" bytes — JSON stringification is close enough for
// a user-facing display, no need for the exact storage-engine cost.
let bytes = 0;
for (const [k, v] of Object.entries(all)) {
if (!k.startsWith('tuner.')) continue;
try { bytes += k.length + JSON.stringify(v).length; } catch { /* nope */ }
}
els.statHistory.textContent = `${history.length} tracks`;
els.statFavs.textContent = `${favs.length} tracks`;
els.statCache.textContent = `${cacheArr.length} stations`;
els.statBytes.textContent = formatBytes(bytes);
}
async function refreshPlayback() {
const stored = await chrome.storage.local.get([
STORAGE_KEYS.volume,
STORAGE_KEYS.currentId,
STORAGE_KEYS.stations,
]);
const vol = stored[STORAGE_KEYS.volume];
els.volumeDisplay.textContent = typeof vol === 'number'
? `${Math.round(vol * 100)}%`
: '—';
const id = stored[STORAGE_KEYS.currentId];
const cache = Array.isArray(stored[STORAGE_KEYS.stations]) ? stored[STORAGE_KEYS.stations] : [];
const cur = cache.find(s => s.id === id);
els.lastStation.textContent = cur?.name || (id || '—');
}
function notifyUis() {
chrome.runtime.sendMessage({ target: TARGETS.POPUP, type: TYPES.STORAGE_WIPED })
.catch(() => {}); // no listeners is fine
}
function formatBytes(n) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}
function toast(message, tone) {
els.toast.textContent = message;
els.toast.dataset.tone = tone || '';
els.toast.hidden = false;
clearTimeout(toast._t);
toast._t = setTimeout(() => { els.toast.hidden = true; }, 2400);
}
+45 -1
View File
@@ -245,11 +245,55 @@ html, body {
font-size: 12px; font-size: 12px;
} }
/* Nav — quick-link row above the footer */
.tuner-nav {
display: grid;
grid-template-columns: repeat(3, 1fr);
background: var(--bg-soft);
border-top: 1px solid #000;
gap: 1px;
}
.tuner-nav-btn {
background: var(--bg-soft);
color: var(--fg-muted);
border: 0;
padding: 8px 4px;
font: inherit;
font-size: 11px;
font-weight: 500;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
font-family: inherit;
}
.tuner-nav-btn:hover {
background: var(--bg-row);
color: var(--fg);
}
.tuner-nav-btn:active {
background: var(--bg-row-hi);
}
.tuner-nav-icon {
font-size: 14px;
line-height: 1;
color: var(--accent);
}
.tuner-nav-label {
letter-spacing: 0.3px;
}
/* Footer */ /* Footer */
.tuner-footer { .tuner-footer {
padding: 6px 12px; padding: 6px 12px;
background: var(--bg-soft); background: var(--bg-soft);
border-top: 1px solid #000; border-top: 1px solid var(--bg-row);
font-size: 10px; font-size: 10px;
color: var(--fg-muted); color: var(--fg-muted);
text-align: center; text-align: center;
+15
View File
@@ -36,6 +36,21 @@
</ul> </ul>
</section> </section>
<nav class="tuner-nav" aria-label="Quick links">
<button id="nav-open-tab" class="tuner-nav-btn" type="button" title="Open full Tuner in a new tab">
<span class="tuner-nav-icon" aria-hidden="true"></span>
<span class="tuner-nav-label">Open in tab</span>
</button>
<button id="nav-history" class="tuner-nav-btn" type="button" title="Open Tuner tab on the History pane">
<span class="tuner-nav-icon" aria-hidden="true"></span>
<span class="tuner-nav-label">History</span>
</button>
<button id="nav-options" class="tuner-nav-btn" type="button" title="Open extension settings">
<span class="tuner-nav-icon" aria-hidden="true"></span>
<span class="tuner-nav-label">Settings</span>
</button>
</nav>
<footer class="tuner-footer"> <footer class="tuner-footer">
<span>SomaFM • indie radio • no telemetry</span> <span>SomaFM • indie radio • no telemetry</span>
</footer> </footer>
+47 -7
View File
@@ -13,8 +13,13 @@ const els = {
volume: document.getElementById('volume'), volume: document.getElementById('volume'),
search: document.getElementById('search'), search: document.getElementById('search'),
list: document.getElementById('station-list'), list: document.getElementById('station-list'),
navOpenTab: document.getElementById('nav-open-tab'),
navHistory: document.getElementById('nav-history'),
navOptions: document.getElementById('nav-options'),
}; };
const NEWTAB_URL = chrome.runtime.getURL('src/newtab/newtab.html');
const STORAGE_KEYS = { const STORAGE_KEYS = {
stations: 'tuner.stationsCache', stations: 'tuner.stationsCache',
cachedAt: 'tuner.stationsCachedAt', cachedAt: 'tuner.stationsCachedAt',
@@ -77,6 +82,18 @@ async function init() {
renderStations(); renderStations();
}); });
// Nav buttons — popup closes automatically when a tab opens, which is
// the expected UX. No popup.close() call needed.
els.navOpenTab.addEventListener('click', () => {
chrome.tabs.create({ url: NEWTAB_URL });
});
els.navHistory.addEventListener('click', () => {
chrome.tabs.create({ url: `${NEWTAB_URL}#history` });
});
els.navOptions.addEventListener('click', () => {
chrome.runtime.openOptionsPage();
});
// 6. Listen for state changes broadcast from offscreen. // 6. Listen for state changes broadcast from offscreen.
chrome.runtime.onMessage.addListener((msg) => { chrome.runtime.onMessage.addListener((msg) => {
if (msg.target !== TARGETS.POPUP) return; if (msg.target !== TARGETS.POPUP) return;
@@ -95,7 +112,26 @@ async function init() {
} }
}); });
// 7. Enable the play button if we have a station picked. // 7. Cross-surface sync — if the New Tab Page (or another popup
// instance) picks a station, mirror it here without a reload.
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local') return;
if (changes[STORAGE_KEYS.currentId] && stations.length) {
const newId = changes[STORAGE_KEYS.currentId].newValue;
currentStation = stations.find(s => s.id === newId) || null;
els.btnPlay.disabled = !currentStation;
renderNowPlaying();
renderStations();
}
if (changes[STORAGE_KEYS.isPlaying]) {
playing = !!changes[STORAGE_KEYS.isPlaying].newValue;
reflectPlayButton();
}
});
// 8. Enable the play button if we have a station picked.
els.btnPlay.disabled = !currentStation; els.btnPlay.disabled = !currentStation;
} }
@@ -191,13 +227,17 @@ async function playCurrent() {
const streamUrl = await source.resolveStreamUrl(currentStation); const streamUrl = await source.resolveStreamUrl(currentStation);
// Push current volume first so the first frame plays at the right level. // 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 }); await sendToSW({ type: TYPES.SET_VOLUME, volume: Number(els.volume.value) / 100 });
const resp = await sendToSW({ type: TYPES.PLAY, streamUrl }); // Pass a stripped station object — offscreen needs id/sourceId/name to
// poll metadata + label history entries; the rest is UI-only.
const stationLite = {
id: currentStation.id,
sourceId: currentStation.sourceId,
name: currentStation.name,
};
const resp = await sendToSW({ type: TYPES.PLAY, streamUrl, station: stationLite });
if (!resp?.ok) throw new Error(resp?.error || 'PLAY failed'); if (!resp?.ok) throw new Error(resp?.error || 'PLAY failed');
// The offscreen poll loop will broadcast METADATA_UPDATED within ~1s,
// Fire-and-forget now-playing fetch. // so we don't fire a separate one-shot fetch from here any more.
source.getNowPlaying(currentStation).then(np => {
if (np) els.npTrack.textContent = formatNowPlaying(np);
}).catch(() => {});
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setState('error'); setState('error');