Compare commits
19 Commits
v0.4.0-prep
...
v0.5.3
| Author | SHA1 | Date | |
|---|---|---|---|
| dbbcf55b2b | |||
| 131569963a | |||
| ada3c4e18a | |||
| 60331b8350 | |||
| 529409eed9 | |||
| 88f80a27c1 | |||
| 89c26a1ad5 | |||
| 2ed4bcffa9 | |||
| 04dc3b70ec | |||
| 6cddfccc3d | |||
| 609e0edf60 | |||
| c3e3cc16fd | |||
| 7c476315f5 | |||
| d0d5e76abe | |||
| 5510cebde1 | |||
| eb6901f722 | |||
| 41f42f1048 | |||
| f796fe8223 | |||
| afaff271a4 |
+249
-5
@@ -7,11 +7,255 @@ Format: [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) — versi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Planned — Tier 3 (Chrome Web Store submission)
|
### Planned — Long-term
|
||||||
- Listing assets: 1280x800 screenshots, 440x280 promo tile
|
- `.m3u` parser alongside `.pls` to widen future-adapter compatibility
|
||||||
- Privacy policy URL published at `davidtkeane.com/rangerhq-tuner/privacy`
|
- Station artwork lazy-load + fallback to family helmet
|
||||||
- 2-Step Verification on Google account
|
- Better error UI for failed streams ("Stream unavailable, try another")
|
||||||
- $5 dev fee + submission
|
- `.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.js` — `initQuickStationsUI` 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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.js` — `QUICK_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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.5.0] — 2026-06-10 — Light mode + theme toggle + Options back-link
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Added — Options page → Tuner back-link
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "RangerHQ Tuner",
|
"name": "RangerHQ Tuner",
|
||||||
"version": "0.3.0",
|
"version": "0.5.3",
|
||||||
"description": "Lightweight SomaFM radio player. Logs what plays. One-click search to Spotify, YouTube, Apple Music, Bandcamp. No telemetry.",
|
"description": "Lightweight SomaFM radio player. Logs what plays. One-click search to Spotify, YouTube, Apple Music, Bandcamp. No telemetry.",
|
||||||
"author": "David Keane",
|
"author": "David Keane",
|
||||||
"homepage_url": "https://davidtkeane.com/rangerhq-tuner",
|
"homepage_url": "https://davidtkeane.com/rangerhq-tuner",
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// RangerHQ Tuner — Quick Stations preference helpers (v0.5.3).
|
||||||
|
//
|
||||||
|
// The user picks which SomaFM channels appear in the Quick Stations chip
|
||||||
|
// row on the New Tab Page. Stored in chrome.storage.local under
|
||||||
|
// `tuner.quickStations` as an array of SomaFM short ids
|
||||||
|
// (e.g. ['groovesalad', 'defcon', ...]). If the key is missing, the
|
||||||
|
// DEFAULT_QUICK_IDS below are used — a tidy 8 that wraps to 2 rows on
|
||||||
|
// most viewports.
|
||||||
|
//
|
||||||
|
// Cross-surface sync: NewTab's `chrome.storage.onChanged` listener picks
|
||||||
|
// up changes so toggling a checkbox in Options re-renders the chip row
|
||||||
|
// instantly without a reload.
|
||||||
|
|
||||||
|
export const QUICK_STATIONS_KEY = 'tuner.quickStations';
|
||||||
|
|
||||||
|
// 8 curated defaults — the same set Tuner v0.4.0 shipped with. Chosen
|
||||||
|
// for genre breadth (chill / ambient / indie / lounge / space / vocal /
|
||||||
|
// ambient / electronic) and to fit a tidy 2-row chip layout.
|
||||||
|
export const DEFAULT_QUICK_IDS = Object.freeze([
|
||||||
|
'groovesalad',
|
||||||
|
'dronezone',
|
||||||
|
'indiepop',
|
||||||
|
'secretagent',
|
||||||
|
'spacestation',
|
||||||
|
'lush',
|
||||||
|
'deepspaceone',
|
||||||
|
'fluid',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function storage() {
|
||||||
|
if (typeof chrome === 'undefined') return null;
|
||||||
|
if (!chrome.storage || !chrome.storage.local) return null;
|
||||||
|
return chrome.storage.local;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the user's chosen quick-station list, falling back to defaults. */
|
||||||
|
export async function getQuickStations() {
|
||||||
|
const s = storage();
|
||||||
|
if (!s) return [...DEFAULT_QUICK_IDS];
|
||||||
|
const o = await s.get(QUICK_STATIONS_KEY);
|
||||||
|
const v = o[QUICK_STATIONS_KEY];
|
||||||
|
return Array.isArray(v) ? v.filter(x => typeof x === 'string') : [...DEFAULT_QUICK_IDS];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the user's chosen quick-station list. Empty array = no quick chips. */
|
||||||
|
export async function setQuickStations(ids) {
|
||||||
|
const s = storage();
|
||||||
|
if (!s) return;
|
||||||
|
const arr = Array.isArray(ids) ? ids.filter(x => typeof x === 'string') : [];
|
||||||
|
await s.set({ [QUICK_STATIONS_KEY]: arr });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wipe the preference so the default-8 is used again. */
|
||||||
|
export async function resetQuickStations() {
|
||||||
|
const s = storage();
|
||||||
|
if (!s) return;
|
||||||
|
await s.remove(QUICK_STATIONS_KEY);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// RangerHQ Tuner — Theme helpers (v0.5.0)
|
||||||
|
//
|
||||||
|
// Three modes:
|
||||||
|
// auto — follow OS via @media (prefers-color-scheme: light)
|
||||||
|
// dark — force dark regardless of OS
|
||||||
|
// light — force light regardless of OS
|
||||||
|
//
|
||||||
|
// Stored in chrome.storage.local under `tuner.theme`. Default: auto.
|
||||||
|
//
|
||||||
|
// Application strategy: set/remove `data-theme` attribute on <html>.
|
||||||
|
// - "auto" → attribute removed → @media in each stylesheet decides
|
||||||
|
// - "dark" → data-theme="dark" → CSS forces dark vars
|
||||||
|
// - "light" → data-theme="light" → CSS forces light vars
|
||||||
|
//
|
||||||
|
// Used from popup.js, newtab.js, and options.js on init + on cross-surface
|
||||||
|
// sync.
|
||||||
|
|
||||||
|
export const THEME_KEY = 'tuner.theme';
|
||||||
|
export const THEME_AUTO = 'auto';
|
||||||
|
export const THEME_DARK = 'dark';
|
||||||
|
export const THEME_LIGHT = 'light';
|
||||||
|
export const THEME_DEFAULT = THEME_AUTO;
|
||||||
|
export const VALID_THEMES = [THEME_AUTO, THEME_DARK, THEME_LIGHT];
|
||||||
|
|
||||||
|
/** Read the stored theme preference, falling back to "auto". */
|
||||||
|
export async function getTheme() {
|
||||||
|
if (typeof chrome === 'undefined' || !chrome.storage?.local) return THEME_DEFAULT;
|
||||||
|
const o = await chrome.storage.local.get(THEME_KEY);
|
||||||
|
const v = o[THEME_KEY];
|
||||||
|
return VALID_THEMES.includes(v) ? v : THEME_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist a theme preference. Any cross-surface listeners will pick it up. */
|
||||||
|
export async function setTheme(theme) {
|
||||||
|
if (!VALID_THEMES.includes(theme)) theme = THEME_DEFAULT;
|
||||||
|
if (typeof chrome === 'undefined' || !chrome.storage?.local) return theme;
|
||||||
|
await chrome.storage.local.set({ [THEME_KEY]: theme });
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a theme to the current document. Pass "auto" to clear the override. */
|
||||||
|
export function applyTheme(theme) {
|
||||||
|
const t = VALID_THEMES.includes(theme) ? theme : THEME_DEFAULT;
|
||||||
|
const html = document.documentElement;
|
||||||
|
if (t === THEME_AUTO) {
|
||||||
|
html.removeAttribute('data-theme');
|
||||||
|
} else {
|
||||||
|
html.setAttribute('data-theme', t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: read the stored theme and apply it. */
|
||||||
|
export async function initTheme() {
|
||||||
|
applyTheme(await getTheme());
|
||||||
|
}
|
||||||
+51
-1
@@ -17,8 +17,58 @@
|
|||||||
--danger: #c9685b;
|
--danger: #c9685b;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--maxw: 760px;
|
--maxw: 760px;
|
||||||
|
--watermark-opacity: 0.025;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────────────────
|
||||||
|
Light mode (v0.5.0) — auto-follows OS theme. Same approach as popup:
|
||||||
|
override :root vars so every existing rule using var(--*) keeps
|
||||||
|
working unchanged. Watermark opacity bumped slightly since the
|
||||||
|
helmet contrasts more against light bg.
|
||||||
|
────────────────────────────────────────────────────────────────────── */
|
||||||
|
/* OS-follow light mode */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--bg: #f6f4ed;
|
||||||
|
--bg-soft: #ece5d2;
|
||||||
|
--bg-row: #ddd6c0;
|
||||||
|
--bg-row-hi: #c8c0a8;
|
||||||
|
--fg: #2a2f28;
|
||||||
|
--fg-muted: #6a7064;
|
||||||
|
--accent: #2a7d3e;
|
||||||
|
--accent-dim: #6dbf7a;
|
||||||
|
--cream: #b8861a;
|
||||||
|
--danger: #b53a2b;
|
||||||
|
--watermark-opacity: 0.04;
|
||||||
|
}
|
||||||
|
.nt-state-pill[data-state="playing"] { color: #fff; }
|
||||||
|
.nt-state-pill[data-state="buffering"] { color: #fff; }
|
||||||
|
.nt-state-pill[data-state="error"] { color: #fff; }
|
||||||
|
.nt-btn--primary[aria-pressed="true"] { color: #fff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual override — force LIGHT */
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--bg: #f6f4ed; --bg-soft: #ece5d2; --bg-row: #ddd6c0; --bg-row-hi: #c8c0a8;
|
||||||
|
--fg: #2a2f28; --fg-muted: #6a7064; --accent: #2a7d3e; --accent-dim: #6dbf7a;
|
||||||
|
--cream: #b8861a; --danger: #b53a2b; --watermark-opacity: 0.04;
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .nt-state-pill[data-state="playing"] { color: #fff; }
|
||||||
|
html[data-theme="light"] .nt-state-pill[data-state="buffering"] { color: #fff; }
|
||||||
|
html[data-theme="light"] .nt-state-pill[data-state="error"] { color: #fff; }
|
||||||
|
html[data-theme="light"] .nt-btn--primary[aria-pressed="true"] { color: #fff; }
|
||||||
|
|
||||||
|
/* Manual override — force DARK */
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--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; --watermark-opacity: 0.025;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .nt-state-pill[data-state="playing"] { color: var(--bg); }
|
||||||
|
html[data-theme="dark"] .nt-state-pill[data-state="buffering"] { color: var(--bg); }
|
||||||
|
html[data-theme="dark"] .nt-state-pill[data-state="error"] { color: var(--cream); }
|
||||||
|
html[data-theme="dark"] .nt-btn--primary[aria-pressed="true"] { color: var(--bg); }
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
@@ -40,7 +90,7 @@ body::before {
|
|||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-size: min(60vh, 600px);
|
background-size: min(60vh, 600px);
|
||||||
opacity: 0.025;
|
opacity: var(--watermark-opacity);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-14
@@ -13,6 +13,8 @@ import {
|
|||||||
searchUrls, formatRelativeTime,
|
searchUrls, formatRelativeTime,
|
||||||
entrySignature,
|
entrySignature,
|
||||||
} from '../lib/history.js';
|
} from '../lib/history.js';
|
||||||
|
import { THEME_KEY, THEME_DEFAULT, initTheme, applyTheme } from '../lib/theme.js';
|
||||||
|
import { QUICK_STATIONS_KEY, getQuickStations } from '../lib/quick-stations.js';
|
||||||
|
|
||||||
const els = {
|
const els = {
|
||||||
// Clock
|
// Clock
|
||||||
@@ -54,18 +56,11 @@ const STORAGE_KEYS = {
|
|||||||
|
|
||||||
const CATALOGUE_TTL_MS = 6 * 60 * 60 * 1000;
|
const CATALOGUE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
// Quick-pick stations. These are the SomaFM ids most people start with.
|
// Quick-pick stations come from chrome.storage.local under tuner.quickStations,
|
||||||
// If the catalogue is missing any (rare), they're just dropped silently.
|
// with DEFAULT_QUICK_IDS as fallback (8 curated for tidy 2-row layout).
|
||||||
const QUICK_IDS = [
|
// User can pick their own set in the Options page's "Quick Stations" card.
|
||||||
'groovesalad',
|
// See src/lib/quick-stations.js for the persistence helpers + defaults.
|
||||||
'dronezone',
|
let quickIds = [];
|
||||||
'indiepop',
|
|
||||||
'secretagent',
|
|
||||||
'spacestation',
|
|
||||||
'lush',
|
|
||||||
'deepspaceone',
|
|
||||||
'fluid',
|
|
||||||
];
|
|
||||||
|
|
||||||
let stations = [];
|
let stations = [];
|
||||||
let currentStation = null;
|
let currentStation = null;
|
||||||
@@ -80,7 +75,11 @@ init().catch(err => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
// Clock first — visible even before stations load.
|
// Theme first — apply stored preference before anything paints to avoid
|
||||||
|
// a brief dark flash if the user has picked light.
|
||||||
|
await initTheme();
|
||||||
|
|
||||||
|
// Clock — visible even before stations load.
|
||||||
tickClock();
|
tickClock();
|
||||||
clockTimer = setInterval(tickClock, 1000); // 1 Hz — live seconds
|
clockTimer = setInterval(tickClock, 1000); // 1 Hz — live seconds
|
||||||
|
|
||||||
@@ -110,6 +109,7 @@ async function init() {
|
|||||||
currentStation = stations.find(s => s.id === currentId) || null;
|
currentStation = stations.find(s => s.id === currentId) || null;
|
||||||
reflectFirstRunHint();
|
reflectFirstRunHint();
|
||||||
renderNow();
|
renderNow();
|
||||||
|
quickIds = await getQuickStations();
|
||||||
renderQuick();
|
renderQuick();
|
||||||
renderList();
|
renderList();
|
||||||
|
|
||||||
@@ -181,6 +181,15 @@ async function init() {
|
|||||||
chrome.storage.onChanged.addListener(async (changes, area) => {
|
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||||
if (area !== 'local') return;
|
if (area !== 'local') return;
|
||||||
|
|
||||||
|
if (changes[THEME_KEY]) {
|
||||||
|
applyTheme(changes[THEME_KEY].newValue || THEME_DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes[QUICK_STATIONS_KEY]) {
|
||||||
|
quickIds = await getQuickStations();
|
||||||
|
renderQuick();
|
||||||
|
}
|
||||||
|
|
||||||
if (changes[STORAGE_KEYS.currentId] && stations.length) {
|
if (changes[STORAGE_KEYS.currentId] && stations.length) {
|
||||||
const newId = changes[STORAGE_KEYS.currentId].newValue;
|
const newId = changes[STORAGE_KEYS.currentId].newValue;
|
||||||
currentStation = stations.find(s => s.id === newId) || null;
|
currentStation = stations.find(s => s.id === newId) || null;
|
||||||
@@ -243,7 +252,14 @@ function renderQuick() {
|
|||||||
els.quickList.appendChild(empty);
|
els.quickList.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const shortId of QUICK_IDS) {
|
if (!quickIds.length) {
|
||||||
|
const empty = document.createElement('span');
|
||||||
|
empty.className = 'nt-quick-empty';
|
||||||
|
empty.textContent = 'No Quick Stations picked — set some in ⚙ Settings.';
|
||||||
|
els.quickList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const shortId of quickIds) {
|
||||||
const fullId = `somafm:${shortId}`;
|
const fullId = `somafm:${shortId}`;
|
||||||
const s = stations.find(st => st.id === fullId);
|
const s = stations.find(st => st.id === fullId);
|
||||||
if (!s) continue;
|
if (!s) continue;
|
||||||
|
|||||||
+205
-1
@@ -16,6 +16,91 @@
|
|||||||
--radius: 6px;
|
--radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────────────────
|
||||||
|
Light mode (v0.5.0) — auto-follows OS theme. Same approach as popup
|
||||||
|
+ newtab: flip the :root vars only. Existing rules using var(--*)
|
||||||
|
carry over unchanged. Toast text + danger-button hover adjusted for
|
||||||
|
light-bg readability.
|
||||||
|
────────────────────────────────────────────────────────────────────── */
|
||||||
|
/* OS-follow light mode */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--bg: #f6f4ed;
|
||||||
|
--bg-soft: #ece5d2;
|
||||||
|
--bg-row: #ddd6c0;
|
||||||
|
--bg-row-hi: #c8c0a8;
|
||||||
|
--fg: #2a2f28;
|
||||||
|
--fg-muted: #6a7064;
|
||||||
|
--accent: #2a7d3e;
|
||||||
|
--accent-dim: #6dbf7a;
|
||||||
|
--cream: #b8861a;
|
||||||
|
--danger: #b53a2b;
|
||||||
|
}
|
||||||
|
.opt-toast { color: #fff; }
|
||||||
|
.opt-toast[data-tone="error"] { color: #fff; }
|
||||||
|
.opt-btn--danger:hover { color: #fff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual override — force LIGHT */
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--bg: #f6f4ed; --bg-soft: #ece5d2; --bg-row: #ddd6c0; --bg-row-hi: #c8c0a8;
|
||||||
|
--fg: #2a2f28; --fg-muted: #6a7064; --accent: #2a7d3e; --accent-dim: #6dbf7a;
|
||||||
|
--cream: #b8861a; --danger: #b53a2b;
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .opt-toast { color: #fff; }
|
||||||
|
html[data-theme="light"] .opt-toast[data-tone="error"] { color: #fff; }
|
||||||
|
html[data-theme="light"] .opt-btn--danger:hover { color: #fff; }
|
||||||
|
|
||||||
|
/* Manual override — force DARK */
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--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;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .opt-toast { color: var(--bg); }
|
||||||
|
html[data-theme="dark"] .opt-toast[data-tone="error"] { color: var(--cream); }
|
||||||
|
html[data-theme="dark"] .opt-btn--danger:hover { color: var(--cream); }
|
||||||
|
|
||||||
|
/* Theme toggle UI (v0.5.0 Phase 2) */
|
||||||
|
.opt-theme-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-theme-row label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 90px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--bg-row-hi);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-row);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 120ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-theme-row label:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-theme-row input[type="radio"] {
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-theme-row label:has(input:checked) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-row-hi);
|
||||||
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
@@ -40,7 +125,29 @@ html, body {
|
|||||||
.opt-brand {
|
.opt-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: -4px -8px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-brand:hover {
|
||||||
|
background: var(--bg-row);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-brand:hover .opt-back {
|
||||||
|
color: var(--accent);
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-back {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 400;
|
||||||
|
transition: color 120ms ease-out, transform 120ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opt-helmet {
|
.opt-helmet {
|
||||||
@@ -260,3 +367,100 @@ html, body {
|
|||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
color: var(--cream);
|
color: var(--cream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────────────────
|
||||||
|
Quick Stations picker (v0.5.3) — scrollable checkbox list of every
|
||||||
|
SomaFM channel. Toggles persist to chrome.storage.local under
|
||||||
|
tuner.quickStations; NewTab re-renders chip row instantly.
|
||||||
|
────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.opt-qs-count {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-qs-list {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--bg-row);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-row);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-qs-list::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
.opt-qs-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-row-hi);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.opt-qs-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-qs-loading {
|
||||||
|
padding: 14px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-qs-row {
|
||||||
|
border-bottom: 1px solid var(--bg-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-qs-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-qs-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-qs-label:hover {
|
||||||
|
background: var(--bg-row-hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-qs-label input[type="checkbox"] {
|
||||||
|
margin: 3px 0 0 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-qs-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-qs-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-qs-genre {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt-qs-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
margin-top: 3px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,10 +9,11 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header class="opt-header">
|
<header class="opt-header">
|
||||||
<div class="opt-brand">
|
<a href="../newtab/newtab.html" class="opt-brand" title="Back to RangerHQ Tuner">
|
||||||
|
<span class="opt-back" aria-hidden="true">←</span>
|
||||||
<img src="../assets/img/ranger.png" alt="" class="opt-helmet">
|
<img src="../assets/img/ranger.png" alt="" class="opt-helmet">
|
||||||
<h1>RangerHQ Tuner — Options</h1>
|
<h1>RangerHQ Tuner — Options</h1>
|
||||||
</div>
|
</a>
|
||||||
<span class="opt-version" id="opt-version">v—</span>
|
<span class="opt-version" id="opt-version">v—</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -52,6 +53,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="opt-actions">
|
||||||
|
<button id="opt-export-json" class="opt-btn">Export history (JSON)</button>
|
||||||
|
<button id="opt-export-csv" class="opt-btn">Export history (CSV)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="opt-actions">
|
<div class="opt-actions">
|
||||||
<button id="opt-clear-history" class="opt-btn">Clear history</button>
|
<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-favs" class="opt-btn">Clear favourites</button>
|
||||||
@@ -59,6 +65,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="opt-card">
|
||||||
|
<h2>Appearance</h2>
|
||||||
|
<div class="opt-row">
|
||||||
|
<label>
|
||||||
|
Theme
|
||||||
|
<span class="opt-help">Auto follows your OS setting. Dark / Light overrides it.</span>
|
||||||
|
</label>
|
||||||
|
<div class="opt-theme-row" role="radiogroup" aria-label="Theme">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="opt-theme" value="auto" id="opt-theme-auto">
|
||||||
|
<span>Auto</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="opt-theme" value="dark" id="opt-theme-dark">
|
||||||
|
<span>Dark</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="opt-theme" value="light" id="opt-theme-light">
|
||||||
|
<span>Light</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="opt-card">
|
||||||
|
<h2>Quick Stations</h2>
|
||||||
|
<p class="opt-note">
|
||||||
|
Pick which SomaFM channels show in the chip row on the New Tab Page.
|
||||||
|
<span id="opt-qs-count" class="opt-qs-count">— selected</span>
|
||||||
|
</p>
|
||||||
|
<ul id="opt-qs-list" class="opt-qs-list" role="list">
|
||||||
|
<li class="opt-qs-loading">Loading channel list…</li>
|
||||||
|
</ul>
|
||||||
|
<div class="opt-actions">
|
||||||
|
<button id="opt-qs-reset" class="opt-btn" type="button">Reset to defaults (8)</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="opt-card">
|
<section class="opt-card">
|
||||||
<h2>Playback</h2>
|
<h2>Playback</h2>
|
||||||
<div class="opt-row opt-row--inline">
|
<div class="opt-row opt-row--inline">
|
||||||
|
|||||||
+210
-1
@@ -9,6 +9,15 @@ import {
|
|||||||
getHistoryCap, setHistoryCap,
|
getHistoryCap, setHistoryCap,
|
||||||
clearHistory, clearFavourites, clearAll,
|
clearHistory, clearFavourites, clearAll,
|
||||||
} from '../lib/history.js';
|
} from '../lib/history.js';
|
||||||
|
import { listAllStations } from '../sources/index.js';
|
||||||
|
import {
|
||||||
|
THEME_KEY, THEME_DEFAULT, VALID_THEMES,
|
||||||
|
getTheme, setTheme, applyTheme,
|
||||||
|
} from '../lib/theme.js';
|
||||||
|
import {
|
||||||
|
QUICK_STATIONS_KEY, DEFAULT_QUICK_IDS,
|
||||||
|
getQuickStations, setQuickStations, resetQuickStations,
|
||||||
|
} from '../lib/quick-stations.js';
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
stations: 'tuner.stationsCache',
|
stations: 'tuner.stationsCache',
|
||||||
@@ -30,6 +39,13 @@ const els = {
|
|||||||
volumeDisplay: document.getElementById('opt-volume-display'),
|
volumeDisplay: document.getElementById('opt-volume-display'),
|
||||||
lastStation: document.getElementById('opt-last-station'),
|
lastStation: document.getElementById('opt-last-station'),
|
||||||
toast: document.getElementById('opt-toast'),
|
toast: document.getElementById('opt-toast'),
|
||||||
|
// Quick Stations picker (v0.5.3)
|
||||||
|
qsCount: document.getElementById('opt-qs-count'),
|
||||||
|
qsList: document.getElementById('opt-qs-list'),
|
||||||
|
qsReset: document.getElementById('opt-qs-reset'),
|
||||||
|
// Export history (v0.5.3)
|
||||||
|
exportJson: document.getElementById('opt-export-json'),
|
||||||
|
exportCsv: document.getElementById('opt-export-csv'),
|
||||||
};
|
};
|
||||||
|
|
||||||
init().catch(err => {
|
init().catch(err => {
|
||||||
@@ -39,6 +55,14 @@ init().catch(err => {
|
|||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
els.version.textContent = `v${chrome.runtime.getManifest().version}`;
|
els.version.textContent = `v${chrome.runtime.getManifest().version}`;
|
||||||
|
|
||||||
|
// Theme — apply stored preference + wire the radio group + listen for
|
||||||
|
// cross-surface changes (popup/newtab pick a theme → Options reflects).
|
||||||
|
await initThemeUI();
|
||||||
|
|
||||||
|
// Quick Stations — render checkbox list of all SomaFM channels.
|
||||||
|
await initQuickStationsUI();
|
||||||
|
|
||||||
await refreshStats();
|
await refreshStats();
|
||||||
await refreshPlayback();
|
await refreshPlayback();
|
||||||
|
|
||||||
@@ -56,6 +80,9 @@ async function init() {
|
|||||||
await refreshStats();
|
await refreshStats();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
els.exportJson.addEventListener('click', () => exportHistoryAs('json'));
|
||||||
|
els.exportCsv.addEventListener('click', () => exportHistoryAs('csv'));
|
||||||
|
|
||||||
els.clearHistory.addEventListener('click', async () => {
|
els.clearHistory.addEventListener('click', async () => {
|
||||||
if (!confirm('Clear all track history? Favourites will be preserved.')) return;
|
if (!confirm('Clear all track history? Favourites will be preserved.')) return;
|
||||||
await clearHistory();
|
await clearHistory();
|
||||||
@@ -84,8 +111,23 @@ async function init() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Refresh stats live if storage changes from another surface
|
// Refresh stats live if storage changes from another surface
|
||||||
chrome.storage.onChanged.addListener((changes, area) => {
|
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||||
if (area !== 'local') return;
|
if (area !== 'local') return;
|
||||||
|
if (changes[THEME_KEY]) {
|
||||||
|
const v = changes[THEME_KEY].newValue || THEME_DEFAULT;
|
||||||
|
applyTheme(v);
|
||||||
|
const radio = document.querySelector(`input[name="opt-theme"][value="${v}"]`);
|
||||||
|
if (radio) radio.checked = true;
|
||||||
|
}
|
||||||
|
if (changes[QUICK_STATIONS_KEY]) {
|
||||||
|
// Re-check the boxes to match the new picks (in case another surface
|
||||||
|
// wiped storage or reset to defaults).
|
||||||
|
const newPicks = new Set(await getQuickStations());
|
||||||
|
for (const cb of document.querySelectorAll('#opt-qs-list input[type=checkbox]')) {
|
||||||
|
cb.checked = newPicks.has(cb.value);
|
||||||
|
}
|
||||||
|
updateQuickStationsCount(newPicks.size);
|
||||||
|
}
|
||||||
if (Object.keys(changes).some(k => k.startsWith('tuner.'))) {
|
if (Object.keys(changes).some(k => k.startsWith('tuner.'))) {
|
||||||
refreshStats();
|
refreshStats();
|
||||||
refreshPlayback();
|
refreshPlayback();
|
||||||
@@ -93,6 +135,133 @@ async function init() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apply the stored theme, check the matching radio, wire change handlers. */
|
||||||
|
async function initThemeUI() {
|
||||||
|
const current = await getTheme();
|
||||||
|
applyTheme(current);
|
||||||
|
const checked = document.querySelector(`input[name="opt-theme"][value="${current}"]`);
|
||||||
|
if (checked) checked.checked = true;
|
||||||
|
for (const r of document.querySelectorAll('input[name="opt-theme"]')) {
|
||||||
|
r.addEventListener('change', async (e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (!VALID_THEMES.includes(v)) return;
|
||||||
|
await setTheme(v);
|
||||||
|
applyTheme(v);
|
||||||
|
toast(`Theme: ${v}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the Quick Stations picker — checkbox per SomaFM channel from
|
||||||
|
* the cached catalogue, with description below each name. Toggling a
|
||||||
|
* checkbox writes the current picks to chrome.storage.local under
|
||||||
|
* tuner.quickStations. NewTab's storage.onChanged listener re-renders
|
||||||
|
* the chip row instantly without a reload. */
|
||||||
|
async function initQuickStationsUI() {
|
||||||
|
// Try the NewTab cache first (fast path).
|
||||||
|
let cache = (await chrome.storage.local.get('tuner.stationsCache'))['tuner.stationsCache'] || [];
|
||||||
|
|
||||||
|
// Fresh install fallback: if Options is opened BEFORE NewTab has ever
|
||||||
|
// populated the cache, fetch SomaFM channels directly + cache them.
|
||||||
|
if (!cache.length) {
|
||||||
|
try {
|
||||||
|
cache = await listAllStations();
|
||||||
|
// Cache it back so future loads are instant.
|
||||||
|
await chrome.storage.local.set({
|
||||||
|
'tuner.stationsCache': cache,
|
||||||
|
'tuner.stationsCachedAt': Date.now(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Quick Stations: failed to fetch channel list', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stations come in as { id: "somafm:groovesalad", name, description, genre, ... }
|
||||||
|
// Sort alphabetically by name for predictable picker order.
|
||||||
|
const channels = [...cache].sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
const picks = new Set(await getQuickStations());
|
||||||
|
|
||||||
|
els.qsList.innerHTML = '';
|
||||||
|
if (!channels.length) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'opt-qs-loading';
|
||||||
|
li.textContent = 'Could not load SomaFM channel list — check your network connection and reopen Settings.';
|
||||||
|
els.qsList.appendChild(li);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ch of channels) {
|
||||||
|
const shortId = (ch.id || '').split(':')[1];
|
||||||
|
if (!shortId) continue;
|
||||||
|
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'opt-qs-row';
|
||||||
|
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'opt-qs-label';
|
||||||
|
|
||||||
|
const cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.value = shortId;
|
||||||
|
cb.checked = picks.has(shortId);
|
||||||
|
cb.addEventListener('change', onQuickStationToggle);
|
||||||
|
|
||||||
|
const main = document.createElement('div');
|
||||||
|
main.className = 'opt-qs-main';
|
||||||
|
|
||||||
|
const name = document.createElement('div');
|
||||||
|
name.className = 'opt-qs-name';
|
||||||
|
name.textContent = ch.name || shortId;
|
||||||
|
|
||||||
|
if (ch.genre) {
|
||||||
|
const g = document.createElement('div');
|
||||||
|
g.className = 'opt-qs-genre';
|
||||||
|
g.textContent = ch.genre;
|
||||||
|
main.appendChild(name);
|
||||||
|
main.appendChild(g);
|
||||||
|
} else {
|
||||||
|
main.appendChild(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch.description) {
|
||||||
|
const desc = document.createElement('div');
|
||||||
|
desc.className = 'opt-qs-desc';
|
||||||
|
desc.textContent = ch.description;
|
||||||
|
main.appendChild(desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
label.appendChild(cb);
|
||||||
|
label.appendChild(main);
|
||||||
|
li.appendChild(label);
|
||||||
|
els.qsList.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateQuickStationsCount(picks.size);
|
||||||
|
|
||||||
|
els.qsReset.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Reset Quick Stations to the default 8?')) return;
|
||||||
|
await resetQuickStations();
|
||||||
|
// Re-check the boxes to match defaults
|
||||||
|
const defaultSet = new Set(DEFAULT_QUICK_IDS);
|
||||||
|
for (const cb of document.querySelectorAll('#opt-qs-list input[type=checkbox]')) {
|
||||||
|
cb.checked = defaultSet.has(cb.value);
|
||||||
|
}
|
||||||
|
updateQuickStationsCount(defaultSet.size);
|
||||||
|
toast('Quick Stations reset to defaults');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onQuickStationToggle() {
|
||||||
|
const all = [...document.querySelectorAll('#opt-qs-list input[type=checkbox]')];
|
||||||
|
const picked = all.filter(c => c.checked).map(c => c.value);
|
||||||
|
await setQuickStations(picked);
|
||||||
|
updateQuickStationsCount(picked.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuickStationsCount(n) {
|
||||||
|
els.qsCount.textContent = `${n} selected`;
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshStats() {
|
async function refreshStats() {
|
||||||
const [history, favs, all] = await Promise.all([
|
const [history, favs, all] = await Promise.all([
|
||||||
getHistory(),
|
getHistory(),
|
||||||
@@ -143,6 +312,46 @@ function formatBytes(n) {
|
|||||||
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Download tuner.history as a JSON or CSV file. (v0.5.3) */
|
||||||
|
async function exportHistoryAs(format) {
|
||||||
|
const history = await getHistory();
|
||||||
|
if (!history.length) {
|
||||||
|
toast('No history to export yet — play some music first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let blob, ext, mime;
|
||||||
|
if (format === 'json') {
|
||||||
|
blob = new Blob([JSON.stringify(history, null, 2)], { type: 'application/json' });
|
||||||
|
ext = 'json';
|
||||||
|
} else {
|
||||||
|
// CSV — RFC 4180 quoting (doubled quotes, every cell quoted for safety).
|
||||||
|
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||||||
|
const header = 'artist,title,station,station_id,when_iso\n';
|
||||||
|
const rows = history.map(e => [
|
||||||
|
q(e.artist),
|
||||||
|
q(e.title),
|
||||||
|
q(e.station),
|
||||||
|
q(e.stationId),
|
||||||
|
q(e.at ? new Date(e.at).toISOString() : ''),
|
||||||
|
].join(',')).join('\n');
|
||||||
|
blob = new Blob([header + rows + '\n'], { type: 'text/csv;charset=utf-8' });
|
||||||
|
ext = 'csv';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = new Date().toISOString().slice(0, 10);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `rangerhq-tuner-history-${ts}.${ext}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
|
||||||
|
toast(`Exported ${history.length} track${history.length === 1 ? '' : 's'} as ${ext.toUpperCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
function toast(message, tone) {
|
function toast(message, tone) {
|
||||||
els.toast.textContent = message;
|
els.toast.textContent = message;
|
||||||
els.toast.dataset.tone = tone || '';
|
els.toast.dataset.tone = tone || '';
|
||||||
|
|||||||
@@ -14,8 +14,62 @@
|
|||||||
--cream: #f4e9b7;
|
--cream: #f4e9b7;
|
||||||
--danger: #c9685b;
|
--danger: #c9685b;
|
||||||
--radius: 6px;
|
--radius: 6px;
|
||||||
|
|
||||||
|
/* First-run hint pulse colour (RGB tuple — alpha applied in keyframes). */
|
||||||
|
--pulse-rgb: 109, 191, 122;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────────────────
|
||||||
|
Light mode (v0.5.0) — auto-follows OS theme via prefers-color-scheme.
|
||||||
|
Flips backgrounds + foregrounds to a cream-leaning palette while
|
||||||
|
preserving the brand green as the accent. Every existing rule that
|
||||||
|
uses the var(--*) tokens keeps working unchanged.
|
||||||
|
────────────────────────────────────────────────────────────────────── */
|
||||||
|
/* Shared light palette block — used as a mixin by the @media (OS-follow)
|
||||||
|
rule below AND by the html[data-theme="light"] manual override. */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--bg: #f6f4ed; /* cream-leaning off-white */
|
||||||
|
--bg-soft: #ece5d2; /* slightly darker for header bars */
|
||||||
|
--bg-row: #ddd6c0; /* hover background */
|
||||||
|
--bg-row-hi: #c8c0a8; /* active row background */
|
||||||
|
--fg: #2a2f28; /* dark forest-green-leaning text */
|
||||||
|
--fg-muted: #6a7064; /* muted secondary text */
|
||||||
|
--accent: #2a7d3e; /* darker brand green for primary contrast */
|
||||||
|
--accent-dim: #6dbf7a; /* lighter brand green for fills */
|
||||||
|
--cream: #b8861a; /* darker amber — readable on light bg */
|
||||||
|
--danger: #b53a2b; /* darker red — readable on light bg */
|
||||||
|
}
|
||||||
|
.tuner-state[data-state="playing"] { color: #fff; }
|
||||||
|
.tuner-state[data-state="buffering"] { color: #fff; }
|
||||||
|
.tuner-state[data-state="error"] { color: #fff; }
|
||||||
|
.btn-primary[aria-pressed="true"] { color: #fff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manual override — force LIGHT palette regardless of OS. The attribute
|
||||||
|
selector beats both :root and the @media rule's :root on specificity. */
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--bg: #f6f4ed; --bg-soft: #ece5d2; --bg-row: #ddd6c0; --bg-row-hi: #c8c0a8;
|
||||||
|
--fg: #2a2f28; --fg-muted: #6a7064; --accent: #2a7d3e; --accent-dim: #6dbf7a;
|
||||||
|
--cream: #b8861a; --danger: #b53a2b;
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .tuner-state[data-state="playing"] { color: #fff; }
|
||||||
|
html[data-theme="light"] .tuner-state[data-state="buffering"] { color: #fff; }
|
||||||
|
html[data-theme="light"] .tuner-state[data-state="error"] { color: #fff; }
|
||||||
|
html[data-theme="light"] .btn-primary[aria-pressed="true"] { color: #fff; }
|
||||||
|
|
||||||
|
/* Manual override — force DARK palette regardless of OS. Overrides the
|
||||||
|
@media block when OS is light but user explicitly wants dark. */
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--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;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .tuner-state[data-state="playing"] { color: var(--bg); }
|
||||||
|
html[data-theme="dark"] .tuner-state[data-state="buffering"] { color: var(--bg); }
|
||||||
|
html[data-theme="dark"] .tuner-state[data-state="error"] { color: var(--cream); }
|
||||||
|
html[data-theme="dark"] .btn-primary[aria-pressed="true"] { color: var(--bg); }
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { TARGETS, TYPES } from '../lib/messages.js';
|
import { TARGETS, TYPES } from '../lib/messages.js';
|
||||||
import { listAllStations, getSource } from '../sources/index.js';
|
import { listAllStations, getSource } from '../sources/index.js';
|
||||||
|
import { THEME_KEY, THEME_DEFAULT, initTheme, applyTheme } from '../lib/theme.js';
|
||||||
|
|
||||||
const els = {
|
const els = {
|
||||||
statePill: document.getElementById('state-pill'),
|
statePill: document.getElementById('state-pill'),
|
||||||
@@ -42,6 +43,10 @@ init().catch(err => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
|
// 0. Apply theme preference (v0.5.0) before anything paints, to avoid
|
||||||
|
// a brief dark flash if the user has picked light.
|
||||||
|
await initTheme();
|
||||||
|
|
||||||
// 1. Hydrate UI from cached state so the popup renders fast.
|
// 1. Hydrate UI from cached state so the popup renders fast.
|
||||||
const stored = await chrome.storage.local.get(Object.values(STORAGE_KEYS));
|
const stored = await chrome.storage.local.get(Object.values(STORAGE_KEYS));
|
||||||
if (typeof stored[STORAGE_KEYS.volume] === 'number') {
|
if (typeof stored[STORAGE_KEYS.volume] === 'number') {
|
||||||
@@ -118,6 +123,10 @@ async function init() {
|
|||||||
chrome.storage.onChanged.addListener((changes, area) => {
|
chrome.storage.onChanged.addListener((changes, area) => {
|
||||||
if (area !== 'local') return;
|
if (area !== 'local') return;
|
||||||
|
|
||||||
|
if (changes[THEME_KEY]) {
|
||||||
|
applyTheme(changes[THEME_KEY].newValue || THEME_DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
if (changes[STORAGE_KEYS.currentId] && stations.length) {
|
if (changes[STORAGE_KEYS.currentId] && stations.length) {
|
||||||
const newId = changes[STORAGE_KEYS.currentId].newValue;
|
const newId = changes[STORAGE_KEYS.currentId].newValue;
|
||||||
currentStation = stations.find(s => s.id === newId) || null;
|
currentStation = stations.find(s => s.id === newId) || null;
|
||||||
|
|||||||
Reference in New Issue
Block a user