ad43df87c0
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.
192 lines
6.3 KiB
JavaScript
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();
|
|
}
|