Files
rangerhq-tuner/src/lib/history.js
T
ranger ad43df87c0 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.
2026-06-09 00:23:36 +01:00

192 lines
6.3 KiB
JavaScript

// 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();
}