feat: v0.3.0 — history, 4-button search, options page, newtab override
Web Store submission target. Mirrors rangerhq-radio's track-history pattern (inc/history.php) so the family stays coherent across surfaces. Highlights - New Tab Page override (Tier 2.5) — Chrome's default new tab replaced with a RangerHQ-branded landing showing the player, current track, quick chips, searchable browse list, and now history + favourites tabs. - Track history + favourites — capped FIFO 500, dedup against last entry, skip "(unknown)" artist (SomaFM dead-air). Stored in chrome.storage.local under tuner.history + tuner.favourites. - 4-button search per entry — Spotify / YouTube / Apple Music / Bandcamp. Pure public-search-URL link-outs in a new tab, NO auth, NO API keys, NO quota, NO third-party SDK embedded. - Options page (chrome://extensions → details → options) — live stats, history cap slider (50-500), Clear history / Clear favourites / Clear EVERYTHING buttons, About panel with Gitea + davidtkeane.com links. - Popup nav row — Open in tab / History (#hash deep link) / Settings, using chrome.tabs.create + chrome.runtime.openOptionsPage. No new perms. - Cross-surface sync — popup ↔ newtab listen on chrome.storage.onChanged for tuner.currentStationId / tuner.isPlaying / history / favourites. - Storage gateway — offscreen doc can't reliably reach chrome.storage in some Chrome versions, so it sends LOG_TRACK_REQUEST to the SW which does the write. history.js also defensively guards every storage call. - Metadata latency fix — polling now starts immediately on PLAY, in parallel with audio buffer fill. First track display drops from ~10-15s to ~1-2s. Permissions unchanged - Still ["offscreen", "storage"] + somafm.com host only. - chrome.tabs.create works on our own extension URLs without "tabs" perm. - No webRequest, no <all_urls>, no third-party SDK. Bumped from 0.1.0 (last tag on Gitea) directly to 0.3.0. v0.2.0 (newtab + clock) was a working local build but never tagged; its features ship together with v0.3.0 in this single commit.
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
// RangerHQ Tuner — History, Favourites, and 4-button search URLs.
|
||||
//
|
||||
// Mirrors rangerhq-radio's inc/history.php exactly (verified 2026-06-08).
|
||||
// Differences from the WP plugin:
|
||||
// • Chrome has one user per profile, so we use chrome.storage.local,
|
||||
// not per-user usermeta.
|
||||
// • All keys are namespaced under "tuner.*" so clearAll() is trivial.
|
||||
//
|
||||
// Entry shape (mirror WP plugin):
|
||||
// { artist: string, title: string, station: string, stationId: string, at: number }
|
||||
//
|
||||
// Rules (mirror WP plugin):
|
||||
// • Cap at historyCap entries (default 500), FIFO.
|
||||
// • Dedup against the LAST entry only (sig = lower(artist|title|stationId)).
|
||||
// • Skip entries where artist === "(unknown)" (SomaFM dead-air placeholder).
|
||||
|
||||
export const HISTORY_KEY = 'tuner.history';
|
||||
export const FAVS_KEY = 'tuner.favourites';
|
||||
export const CAP_KEY = 'tuner.historyCap';
|
||||
export const DEFAULT_CAP = 500;
|
||||
export const MIN_CAP = 50;
|
||||
export const MAX_CAP = 500;
|
||||
|
||||
/* ---------- Safe storage accessor ----------
|
||||
* chrome.storage is not guaranteed in every extension context (e.g. some
|
||||
* older Chrome versions hide it from offscreen documents). Anywhere it
|
||||
* isn't present, our readers return empty defaults and writers no-op
|
||||
* instead of throwing. This way history.js is safe to import from any
|
||||
* surface — UI pages get real storage, edge contexts degrade silently.
|
||||
*/
|
||||
function storage() {
|
||||
if (typeof chrome === 'undefined') return null;
|
||||
if (!chrome.storage || !chrome.storage.local) return null;
|
||||
return chrome.storage.local;
|
||||
}
|
||||
|
||||
/* ---------- Readers ---------- */
|
||||
|
||||
export async function getHistory() {
|
||||
const s = storage();
|
||||
if (!s) return [];
|
||||
const o = await s.get(HISTORY_KEY);
|
||||
return Array.isArray(o[HISTORY_KEY]) ? o[HISTORY_KEY] : [];
|
||||
}
|
||||
|
||||
export async function getFavourites() {
|
||||
const s = storage();
|
||||
if (!s) return [];
|
||||
const o = await s.get(FAVS_KEY);
|
||||
return Array.isArray(o[FAVS_KEY]) ? o[FAVS_KEY] : [];
|
||||
}
|
||||
|
||||
export async function getHistoryCap() {
|
||||
const s = storage();
|
||||
if (!s) return DEFAULT_CAP;
|
||||
const o = await s.get(CAP_KEY);
|
||||
const n = o[CAP_KEY];
|
||||
return (typeof n === 'number' && n >= MIN_CAP && n <= MAX_CAP) ? n : DEFAULT_CAP;
|
||||
}
|
||||
|
||||
/* ---------- Sanitisation + dedup ---------- */
|
||||
|
||||
function sanitizeEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const artist = String(entry.artist || '').trim().slice(0, 200);
|
||||
const title = String(entry.title || '').trim().slice(0, 200);
|
||||
const station = String(entry.station || '').trim().slice(0, 100);
|
||||
const stationId = String(entry.stationId || '').trim().slice(0, 100);
|
||||
if (!artist || !title) return null;
|
||||
if (artist.toLowerCase() === '(unknown)') return null;
|
||||
return { artist, title, station, stationId, at: Date.now() };
|
||||
}
|
||||
|
||||
export function entrySignature(e) {
|
||||
return [
|
||||
(e.artist || '').toLowerCase().trim(),
|
||||
(e.title || '').toLowerCase().trim(),
|
||||
(e.stationId || '').toLowerCase().trim(),
|
||||
].join('|');
|
||||
}
|
||||
|
||||
/* ---------- Mutations ---------- */
|
||||
|
||||
/** Append a track to history (deduped + capped). Returns true if appended. */
|
||||
export async function logTrack(entry) {
|
||||
const s = storage();
|
||||
if (!s) return false;
|
||||
|
||||
const clean = sanitizeEntry(entry);
|
||||
if (!clean) return false;
|
||||
|
||||
const history = await getHistory();
|
||||
if (history.length > 0) {
|
||||
const lastSig = entrySignature(history[history.length - 1]);
|
||||
if (entrySignature(clean) === lastSig) return false;
|
||||
}
|
||||
|
||||
history.push(clean);
|
||||
const cap = await getHistoryCap();
|
||||
if (history.length > cap) history.splice(0, history.length - cap);
|
||||
await s.set({ [HISTORY_KEY]: history });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Toggle favourite. Returns new state: true = now favourited, false = removed. */
|
||||
export async function toggleFavourite(entry) {
|
||||
const s = storage();
|
||||
if (!s) return false;
|
||||
|
||||
const clean = sanitizeEntry({ ...entry, at: 0 }); // at gets reset
|
||||
if (!clean) return false;
|
||||
const sig = entrySignature(clean);
|
||||
|
||||
const favs = await getFavourites();
|
||||
const found = favs.findIndex(f => entrySignature(f) === sig);
|
||||
if (found >= 0) {
|
||||
favs.splice(found, 1);
|
||||
await s.set({ [FAVS_KEY]: favs });
|
||||
return false;
|
||||
}
|
||||
clean.at = Date.now();
|
||||
favs.push(clean);
|
||||
await s.set({ [FAVS_KEY]: favs });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function isFavourited(entry) {
|
||||
const clean = sanitizeEntry({ ...entry, at: 0 });
|
||||
if (!clean) return false;
|
||||
const sig = entrySignature(clean);
|
||||
const favs = await getFavourites();
|
||||
return favs.some(f => entrySignature(f) === sig);
|
||||
}
|
||||
|
||||
export async function clearHistory() {
|
||||
const s = storage();
|
||||
if (!s) return;
|
||||
await s.remove(HISTORY_KEY);
|
||||
}
|
||||
|
||||
export async function clearFavourites() {
|
||||
const s = storage();
|
||||
if (!s) return;
|
||||
await s.remove(FAVS_KEY);
|
||||
}
|
||||
|
||||
/** Wipe every `tuner.*` key. Used by the "Clear EVERYTHING" button. */
|
||||
export async function clearAll() {
|
||||
const s = storage();
|
||||
if (!s) return;
|
||||
const all = await s.get(null);
|
||||
const keys = Object.keys(all).filter(k => k.startsWith('tuner.'));
|
||||
if (keys.length) await s.remove(keys);
|
||||
}
|
||||
|
||||
export async function setHistoryCap(n) {
|
||||
const s = storage();
|
||||
if (!s) return DEFAULT_CAP;
|
||||
const cap = Math.max(MIN_CAP, Math.min(MAX_CAP, Math.round(Number(n) || DEFAULT_CAP)));
|
||||
await s.set({ [CAP_KEY]: cap });
|
||||
const history = await getHistory();
|
||||
if (history.length > cap) {
|
||||
await s.set({ [HISTORY_KEY]: history.slice(-cap) });
|
||||
}
|
||||
return cap;
|
||||
}
|
||||
|
||||
/* ---------- 4-button search URLs ---------- */
|
||||
// Mirrors WP plugin lines 126-129 verbatim. NO auth, NO API key, NO quota.
|
||||
// Public search URLs that open in a new tab — user picks what to listen to.
|
||||
|
||||
export function searchUrls(artist, title) {
|
||||
const enc = encodeURIComponent(`${artist || ''} ${title || ''}`.trim());
|
||||
return {
|
||||
spotify: `https://open.spotify.com/search/${enc}`,
|
||||
youtube: `https://www.youtube.com/results?search_query=${enc}`,
|
||||
apple: `https://music.apple.com/search?term=${enc}`,
|
||||
bandcamp: `https://bandcamp.com/search?q=${enc}`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- Display helpers ---------- */
|
||||
|
||||
export function formatRelativeTime(ts) {
|
||||
const diffSec = Math.floor((Date.now() - ts) / 1000);
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
|
||||
if (diffSec < 604800) return `${Math.floor(diffSec / 86400)}d ago`;
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
Reference in New Issue
Block a user