Files
rangerhq-tuner/CHANGELOG.md
T
ranger dbbcf55b2b chore: v0.5.3 — bump version + CHANGELOG entry
Final headline-feature release. Per David: 'this app is kinda done'
once Quick Stations picker + history export ship. After this version,
polish remains but no headline features are missing.

Bundled with v0.5.0's eventual Web Store upload — when v0.5.0
clears re-review, we'll build a v0.5.3 ZIP directly from main and
upload that as the next user-visible update. Users at v0.4.0 jump
straight to v0.5.3, getting all the polish in one notification.
2026-06-10 01:16:55 +01:00

29 KiB
Raw Permalink Blame History

Changelog

All notable changes to RangerHQ Tuner are documented here. Format: Keep a Changelog 1.1.0 — versioning: SemVer.


[Unreleased]

Planned — Long-term

  • .m3u parser alongside .pls to widen future-adapter compatibility
  • Station artwork lazy-load + fallback to family helmet
  • Better error UI for failed streams ("Stream unavailable, try another")
  • .crx packaging instructions in README for sideload users
  • Second source adapter stub to exercise the multi-source UI

[0.5.3] — 2026-06-10 — User-customizable Quick Stations + history export ("app is done")

Per David: "this app is kinda done" — meaning the product surface is feature-complete. After this version, polish remains but no headline features are missing.

Added — User-customizable Quick Stations

Pure-data architecture: the NewTab Quick Stations chip row is now driven by tuner.quickStations in chrome.storage.local rather than a hardcoded QUICK_IDS array. Default 8 (tidy 2-row layout, matches v0.4.0 look). User can pick any subset of all 46 SomaFM channels via a new Options page card.

Where: new "Quick Stations" card on the Options page (sits between Appearance and Playback). Layout: helper text + selected-count badge + scrollable checkbox list + Reset-to-defaults button.

Each list row shows: checkbox + station name + genre pill (accent green, uppercase) + the SomaFM channel description (the same string available as a chip-hover tooltip on the NewTab — now properly visible at the time of picking).

Live sync: ticking a checkbox writes to chrome.storage.local. The NewTab chrome.storage.onChanged listener picks up the change and re-renders the Quick Stations chip row instantly without a reload. Cross-surface consistency is the same pattern as theme + station selection.

Robust fresh-install fallback: if Options is opened BEFORE the NewTab has ever populated the station catalogue cache, initQuickStationsUI fetches SomaFM channels directly via listAllStations() then caches the result back to tuner.stationsCache. Future Options opens are then instant.

Empty-pick case: if the user unticks everything, the Quick Stations row on the NewTab shows "No Quick Stations picked — set some in ⚙ Settings." instead of a silently empty section.

Added — History export (JSON + CSV)

Two new buttons in the Local Data card: Export history (JSON) and Export history (CSV).

  • JSON: full pretty-printed array of tuner.history entries. Best for programmatic re-import or backup.
  • CSV: RFC 4180 quoted (every cell quoted, doubled quotes for embedded quotes). Header: artist,title,station,station_id,when_iso. Opens cleanly in Excel / Numbers / Google Sheets.

Filename: rangerhq-tuner-history-YYYY-MM-DD.{ext}.

Empty-history case shows an error toast ("No history to export yet — play some music first"). Success case shows a green toast with the export count and format.

No new permissions — uses standard Blob + URL.createObjectURL + a programmatic <a> click. The entire export runs client-side in the Options page; nothing is sent off-device.

New / changed files

  • src/lib/quick-stations.js (NEW, ~55 lines) — DEFAULT_QUICK_IDS constant + getQuickStations / setQuickStations / resetQuickStations helpers. Same defensive storage() accessor pattern as lib/history.js and lib/theme.js.
  • src/newtab/newtab.js — removed hardcoded QUICK_IDS array, replaced with module-level quickIds = [] populated during init() from getQuickStations() and re-loaded when chrome.storage.onChanged fires on tuner.quickStations. renderQuick() shows the empty-pick fallback message.
  • src/options/options.html — new "Quick Stations" card + two export buttons in the Local Data card.
  • src/options/options.css — ~90 lines for the picker (scrollable list, row layout, accent-coloured count badge, hover background, brand scrollbar).
  • src/options/options.jsinitQuickStationsUI builds the checkbox list, onQuickStationToggle persists picks, exportHistoryAs generates the download Blob. Plus the storage.onChanged listener was made async to support cross-surface picker sync.

Total: 5 files, +372 / -25 lines (1 new file).

Not changed

  • No new permissions
  • No new host_permissions
  • No new external libraries / SDKs
  • No data migration required
  • Existing user state (current station / volume / history / favourites / theme) survives intact
  • Defaults match v0.4.0's curated Quick Stations exactly — users who never open Options get the same look they had before

Workflow note

This release was bundled into v0.5.0's eventual Web Store upload — when v0.5.0 clears re-review, we build a v0.5.3 ZIP directly from main and upload that instead. Users at v0.4.0 jump straight to v0.5.3 (skipping v0.5.0, v0.5.1, v0.5.2) and get all the polish in a single update notification.


[0.5.2] — 2026-06-10 — Quick Stations fix: replace retired BAGeL with PopTron

Hotfix for v0.5.1's Quick Stations row. David flagged: "the stations still look a bit off" — turned out only 13 chips were rendering instead of the intended 14. Investigation showed BAGeL Radio has been retired from SomaFM's current channels.json catalogue — the render loop's "drop missing slugs silently" behaviour (working as designed) made the chip just disappear.

Changed

  • src/newtab/newtab.js QUICK_IDS array: 'bagel''poptron'. PopTron (alternative / alt-rock) fills the same indie/alt-rock genre slot BAGeL covered, and is currently a live SomaFM channel.

Result

14 chips visible again. Layout reads cleaner — 6+6+2 instead of 6+6+1 (ThistleRadio no longer alone on its own row).

No other change

  • No CSS, no permissions, no dependencies, no behaviour change
  • Single one-line edit in QUICK_IDS
  • Still held from Chrome Web Store upload until v0.5.0 clears re-review

Lesson — SomaFM channel catalogue is mutable

Channels can be retired at any time. The render loop's "drop missing slugs silently" pattern protects against this, but the developer-side responsibility is to verify slugs against the live channels.json before adding to QUICK_IDS:

curl -s https://somafm.com/channels.json | python3 -c "import json,sys; print([c['id'] for c in json.load(sys.stdin)['channels']])"

[0.5.1] — 2026-06-10 — Quick Stations expansion (8 → 14)

Third update of the day. Tagged on Gitea but held from Chrome Web Store upload until v0.5.0 clears re-review — we want the v0.5.0 features (light mode + theme toggle + back-link + first-run UX hint) landing in users' browsers before this stations expansion piles on top.

Added — 6 more channels in the NewTab Quick Stations chip row

Was reading sparse with only 8 chips. The row had space for more. Added six channels for broader genre coverage + two on-brand picks:

Slug Channel Why
defcon DEF CON Radio Electronic / hacker culture — David's personal favourite, on-brand for a cybersecurity-adjacent extension
beatblender Beat Blender Electronic / breakbeat — popular channel
bootliquor Boot Liquor Americana / outlaw country — adds country/twang coverage
u80s Underground 80s 80s indie / synthwave — adds decade coverage
bagel BAGeL Radio Indie / alt-rock — guitar-driven indie
thistle ThistleRadio Celtic / folk — 🇮🇪 Dublin-built nod

Total chip count: 8 → 14. No new files, no new permissions, no new dependencies, no code change other than the QUICK_IDS array in src/newtab/newtab.js.

The render loop already drops any slug missing from SomaFM's channels.json catalogue, so if a channel is retired upstream the chip just disappears silently — no error, no broken state.

Files touched

  • src/newtab/newtab.jsQUICK_IDS array updated from 8 to 14 entries, with per-slug genre comments. +19 / -8 lines.

Total: 1 file, +19/-8 lines.

Not changed

  • No new permissions
  • No new dependencies
  • No CSS / HTML changes
  • No data migration

Shipping note

v0.5.1 is tagged on Gitea on 2026-06-10 but deliberately held from the Chrome Web Store until v0.5.0 clears re-review (currently pending). David's reasoning: "we will ship to web store after the last one gets accepted, that one is important so a user knows what to do" — the v0.5.0 first-run hint + light mode features are higher-value for new users; this stations expansion is a smaller polish that should land after.


Second post-launch update, ~36 hours after v0.4.0 cleared the Chrome Web Store. Three bundled improvements, all UX polish:

Added — Light mode support across all three surfaces

Phase 1 — Auto-follow OS theme. Each stylesheet (popup, NewTab, Options) gets a @media (prefers-color-scheme: light) block that flips the :root CSS variables to a light palette while preserving brand identity. Every existing rule using var(--*) tokens carries over unchanged.

Light palette:

  • --bg #f6f4ed cream-leaning off-white
  • --bg-soft #ece5d2 slightly darker for header bars
  • --bg-row #ddd6c0 hover backgrounds
  • --bg-row-hi #c8c0a8 active rows
  • --fg #2a2f28 dark forest-green-leaning text
  • --accent #2a7d3e darker brand green (preserves "RangerHQ green" identity)
  • --cream #b8861a darker amber (readable on light)
  • --danger #b53a2b darker red (readable on light)

NewTab helmet watermark opacity bumped 0.025 → 0.04 in light mode (helmet contrasts differently on light bg). Wired via a new --watermark-opacity CSS variable.

State pills, primary play button, toasts, and danger button hover all get text-colour overrides for both modes so they stay readable when their background becomes solid accent / cream / danger.

Added — Manual Auto / Dark / Light theme toggle (Phase 2)

Three-radio group in a new "Appearance" card on the Options page. Stored in chrome.storage.local under tuner.theme. Defaults to "auto" (follow OS).

  • Auto — follows OS via @media (prefers-color-scheme: light) (covers ~95% of users)
  • Dark — forces dark palette regardless of OS
  • Light — forces light palette regardless of OS

This exists because there are three independent theme layers (OS, browser-chrome, page CSS) that can disagree. @media (prefers-color-scheme: ...) reads the OS layer only, NOT the browser-chrome theme. So users with mismatched layers (e.g. macOS light + Chrome dark theme installed) can override the auto-follow.

Implementation: data-theme attribute on <html> ( "light" / "dark" / removed for auto). html[data-theme="..."] CSS selectors beat both the default :root and the @media :root on specificity.

New file: src/lib/theme.js — shared getTheme/setTheme/applyTheme/initTheme helpers used by all three entry-point scripts.

The choice syncs across surfaces via chrome.storage.onChanged: pick Light in Options → popup + NewTab flip instantly without reload. Theme applies BEFORE first paint (each init() calls initTheme() first) so there's no dark flash on light-mode-preferring browsers.

The Options page header (helmet + "RangerHQ Tuner — Options") is now a single clickable anchor pointing at newtab.html. Subtle glyph to the left of the helmet; hover shifts the arrow left and tints it accent green. Same-tab navigation — user came IN via Options, goes OUT into the player UI in the same tab. No accumulating Tuner tabs.

Fixes UX gap David flagged on 2026-06-09 night: "when i click the settings button i go to settings but we have no back link to radio."

Files touched

  • src/lib/theme.js (NEW, ~55 lines)
  • src/popup/popup.css (+50 lines — light + dark override blocks)
  • src/popup/popup.js (+8 lines — initTheme call + storage.onChanged hook)
  • src/newtab/newtab.css (+50 lines — light + dark override blocks)
  • src/newtab/newtab.js (+8 lines — same wiring)
  • src/options/options.html (+24 lines — Appearance card)
  • src/options/options.css (+90 lines — light + dark blocks + radio styling)
  • src/options/options.js (+32 lines — radio handlers + cross-surface sync)

Total: 8 files, ~340 lines added.

Not changed

  • No new permissions
  • No new host_permissions
  • No new external libraries
  • No data migration required
  • Existing user state (current station / volume / history / favourites) survives intact

Reviewer expectations

Same-account update with no permission change. Same-day review expected (~hours to 24h), similar to v0.4.0's 24h-ish re-review window per reference_chrome_web_store_rules.


[0.4.0] — 2026-06-09 — First-run UX hint

First post-launch update, shipped the same day v0.3.0 went LIVE on the Chrome Web Store. Pure UX polish — zero new permissions, zero new code dependencies, zero behaviour change for existing users.

Added — Discoverable "pick a station to begin" affordance

Triggered by David's own 30-second panic on first Web Store install (uninstalled the dev build, installed from the Web Store fresh, hit Play, got nothing, then realised "ah, I need to pick a station first"). The product worked correctly — the dev build had a seeded station from hours of testing, the fresh install does not — but the 30-second panic exposed a real first-run-UX gap.

Two layered cues, both pure CSS driven by a body.is-first-run class:

  1. Subtle accent-green glow pulses around the station list (popup) and the Quick Stations chip row (NewTab). Uses a 2.4-second box-shadow keyframe at low alpha (0.18-0.25) — visible but not noisy.
  2. Bouncing ↓ arrow appended to the "Pick a station to begin" text in both surfaces. Uses an ::after pseudo-element with a 1.8-second translateY keyframe.

The is-first-run class is toggled by a tiny reflectFirstRunHint() function called from:

  • init() once stations + currentStation are resolved
  • onPickStation() the moment a user picks
  • the chrome.storage.onChanged listener when another surface picks (so the hint disappears on both surfaces simultaneously via cross-surface sync)

Existing users with a stored tuner.currentStationId never see either cue — the class only attaches when currentStation is null.

Files touched

  • src/popup/popup.css (+35 lines — keyframes + .is-first-run rules)
  • src/popup/popup.js (+11 lines — reflectFirstRunHint() + 3 call sites)
  • src/newtab/newtab.css (+36 lines — same idea, NewTab-namespaced)
  • src/newtab/newtab.js (+10 lines — same pattern)

Total: 4 files, +92 lines, 0 deletions.

Not changed

  • No new permissions
  • No new host_permissions
  • No new external libraries
  • No change to manifest.json beyond the version bump and CHANGELOG-referenced URL
  • No data migration required (no storage shape change)

Same-day context

This update ships the same day:

  • v0.3.0 went LIVE on the Chrome Web Store (~17:08 Dublin, ~15.5h after submission)
  • RangerHQ Radio v1.0.0 stability milestone went LIVE on WordPress.org (~21:51 Dublin)
  • David received a PhD-prep signal from his Research in Computing lecturer at NCI Dublin

A solid day.


[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.00.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).

The toolbar popup now has a three-button nav row above the footer:

  • ⎘ Open in tabchrome.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.
  • ⚙ Settingschrome.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
    "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.


[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 (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)

"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 🪖🎵