diff --git a/CHANGELOG.md b/CHANGELOG.md index 69450c0..548d9a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,133 @@ Format: [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) — versi ## [Unreleased] -### Planned — New Tab Page override (Tier 2.5) -- Replace Chrome's default New Tab Page with a RangerHQ-branded version that surfaces the player, current track, and a quick-station picker. -- Adds `chrome_url_overrides.newtab` to `manifest.json` pointing at `src/newtab/newtab.html`. -- Reuses the popup's CSS palette + source-adapter pattern — no new architectural concepts. +### Planned — Tier 3 (Chrome Web Store submission) +- Listing assets: 1280x800 screenshots, 440x280 promo tile +- Privacy policy URL published at `davidtkeane.com/rangerhq-tuner/privacy` +- 2-Step Verification on Google account +- $5 dev fee + submission + +--- + +## [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 50–500 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 (50–500 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.0` → `0.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). + +#### Popup quick-link nav + +The toolbar popup now has a three-button nav row above the footer: + +- **⎘ Open in tab** — `chrome.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. +- **⚙ Settings** — `chrome.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 `` 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 + ```json + "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. --- diff --git a/manifest.json b/manifest.json index bf69959..791b9ae 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "RangerHQ Tuner", - "version": "0.1.0", - "description": "Lightweight indie internet radio player. Starts with SomaFM, extensible to more networks.", + "version": "0.3.0", + "description": "Lightweight indie internet radio player. Plays SomaFM in your toolbar, logs the tracks you hear, and gives you one-click search links to Spotify, YouTube, Apple Music, and Bandcamp. No telemetry.", "author": "David Keane", "homepage_url": "https://davidtkeane.com/rangerhq-tuner", "icons": { @@ -23,6 +23,10 @@ "service_worker": "src/background/service-worker.js", "type": "module" }, + "chrome_url_overrides": { + "newtab": "src/newtab/newtab.html" + }, + "options_page": "src/options/options.html", "permissions": ["offscreen", "storage"], "host_permissions": [ "https://somafm.com/*", diff --git a/src/background/service-worker.js b/src/background/service-worker.js index 0b35aa3..ae445c7 100644 --- a/src/background/service-worker.js +++ b/src/background/service-worker.js @@ -1,7 +1,12 @@ // RangerHQ Tuner — Service Worker. // Job: open the offscreen audio document on demand and route messages -// between the popup (UI) and the offscreen document (audio engine). -// We hold no state here — Chrome will kill this worker at any time. +// between the popup/newtab (UI) and the offscreen document (audio engine). +// Also acts as the storage gateway for the offscreen doc — offscreen +// docs don't reliably have chrome.storage, so they message us instead. +// We hold no in-memory state here — Chrome will kill this worker at any time. + +import { TARGETS, TYPES } from '../lib/messages.js'; +import { logTrack } from '../lib/history.js'; const OFFSCREEN_PATH = 'src/offscreen/offscreen.html'; @@ -19,15 +24,29 @@ async function ensureOffscreen() { chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { // Only react to messages targeted at the service worker. - if (msg.target !== 'sw') return; + if (msg.target !== TARGETS.SW) return; (async () => { try { + // Storage-gateway path: write to chrome.storage on behalf of the + // offscreen document, which can't always reach it directly. + if (msg.type === TYPES.LOG_TRACK_REQUEST) { + const appended = await logTrack(msg.entry || {}); + if (appended) { + chrome.runtime.sendMessage({ + target: TARGETS.POPUP, + type: TYPES.TRACK_LOGGED, + }).catch(() => {}); // no UI listening is fine + } + sendResponse({ ok: true, appended }); + return; + } + + // Default: forward audio-control messages to the offscreen doc. await ensureOffscreen(); - // Re-route the message to the offscreen document. const response = await chrome.runtime.sendMessage({ ...msg, - target: 'offscreen', + target: TARGETS.OFFSCREEN, }); sendResponse(response); } catch (err) { diff --git a/src/lib/history.js b/src/lib/history.js new file mode 100644 index 0000000..01848b4 --- /dev/null +++ b/src/lib/history.js @@ -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(); +} diff --git a/src/lib/messages.js b/src/lib/messages.js index 54cba62..193e508 100644 --- a/src/lib/messages.js +++ b/src/lib/messages.js @@ -9,11 +9,15 @@ export const TARGETS = Object.freeze({ }); export const TYPES = Object.freeze({ - PLAY: 'PLAY', - PAUSE: 'PAUSE', - SET_VOLUME: 'SET_VOLUME', - GET_STATE: 'GET_STATE', - STATE_CHANGED: 'STATE_CHANGED', - METADATA_UPDATED: 'METADATA_UPDATED', - ERROR: 'ERROR', + PLAY: 'PLAY', + PAUSE: 'PAUSE', + SET_VOLUME: 'SET_VOLUME', + GET_STATE: 'GET_STATE', + STATE_CHANGED: 'STATE_CHANGED', + METADATA_UPDATED: 'METADATA_UPDATED', + LOG_TRACK_REQUEST: 'LOG_TRACK_REQUEST', // offscreen → SW: please write this entry to tuner.history + TRACK_LOGGED: 'TRACK_LOGGED', // SW → UI: a new entry was appended to tuner.history + FAVOURITE_TOGGLED: 'FAVOURITE_TOGGLED', // UI → UI: re-render any visible fav lists + STORAGE_WIPED: 'STORAGE_WIPED', // options → UI: catastrophic reset, re-init from defaults + ERROR: 'ERROR', }); diff --git a/src/newtab/newtab.css b/src/newtab/newtab.css new file mode 100644 index 0000000..42f597a --- /dev/null +++ b/src/newtab/newtab.css @@ -0,0 +1,587 @@ +/* RangerHQ Tuner — New Tab Page. + Full-viewport landing that replaces Chrome's default new tab. + Same earthy palette as the popup, but laid out as a focal page + (player centered + quick stations + browsable list). +*/ + +:root { + --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; + --radius: 8px; + --maxw: 760px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + width: 100%; + min-height: 100vh; + background: var(--bg); + color: var(--fg); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +/* Subtle background watermark — the helmet, very faint */ +body::before { + content: ""; + position: fixed; + inset: 0; + background-image: url('../assets/img/ranger.png'); + background-repeat: no-repeat; + background-position: center; + background-size: min(60vh, 600px); + opacity: 0.025; + pointer-events: none; + z-index: 0; +} + +/* Everything else sits on top */ +.nt-header, .nt-main, .nt-footer { position: relative; z-index: 1; } + +/* ========== Header ========== */ +.nt-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 18px 28px; + border-bottom: 1px solid var(--bg-soft); +} + +.nt-brand { + display: flex; + align-items: center; + gap: 10px; +} + +.nt-helmet { + width: 26px; + height: 26px; + object-fit: contain; +} + +.nt-brand-name { + font-size: 14px; + font-weight: 600; + letter-spacing: 0.4px; +} + +.nt-clock { + text-align: right; +} + +.nt-time { + font-size: 22px; + font-weight: 600; + letter-spacing: 1px; + font-variant-numeric: tabular-nums; + display: inline-flex; + align-items: baseline; + gap: 4px; +} + +.nt-secs { + font-size: 13px; + font-weight: 500; + color: var(--fg-muted); + letter-spacing: 0.5px; +} + +.nt-date { + font-size: 11px; + color: var(--fg-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 2px; +} + +/* ========== Main ========== */ +.nt-main { + max-width: var(--maxw); + margin: 0 auto; + padding: 40px 28px 28px; + display: flex; + flex-direction: column; + gap: 28px; +} + +/* Now playing — large + centered */ +.nt-now { + text-align: center; + padding: 24px 0 8px; +} + +.nt-station { + font-size: 26px; + font-weight: 600; + letter-spacing: 0.2px; + margin-bottom: 6px; +} + +.nt-track { + font-size: 15px; + color: var(--fg-muted); + min-height: 1.4em; + margin-bottom: 16px; +} + +.nt-state-pill { + display: inline-block; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + padding: 4px 12px; + border-radius: 999px; + background: var(--bg-row); + color: var(--fg-muted); +} + +.nt-state-pill[data-state="playing"] { + background: var(--accent); + color: var(--bg); +} + +.nt-state-pill[data-state="buffering"] { + background: var(--cream); + color: var(--bg); +} + +.nt-state-pill[data-state="error"] { + background: var(--danger); + color: var(--cream); +} + +/* Controls — generous click targets */ +.nt-controls { + display: flex; + justify-content: center; + align-items: center; + gap: 16px; + padding: 10px 0; +} + +.nt-btn { + font-size: 15px; + font-weight: 500; + padding: 10px 22px; + border: 1px solid var(--accent-dim); + border-radius: var(--radius); + background: var(--bg-row); + color: var(--fg); + cursor: pointer; +} + +.nt-btn:hover:not(:disabled) { + background: var(--bg-row-hi); +} + +.nt-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.nt-btn--primary { + background: var(--accent-dim); + min-width: 120px; +} + +.nt-btn--primary[aria-pressed="true"] { + background: var(--accent); + color: var(--bg); +} + +.nt-vol-wrap { + display: flex; + align-items: center; + gap: 8px; + min-width: 240px; +} + +.nt-vol-label { + font-size: 11px; + color: var(--fg-muted); + letter-spacing: 1px; + text-transform: uppercase; +} + +#nt-volume { + flex: 1; + accent-color: var(--accent); +} + +/* Quick stations — chip row */ +.nt-quick { + border-top: 1px solid var(--bg-soft); + padding-top: 18px; +} + +.nt-quick-label { + font-size: 11px; + color: var(--fg-muted); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 10px; +} + +.nt-quick-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.nt-quick-chip { + font-size: 12px; + padding: 6px 12px; + border: 1px solid var(--bg-row-hi); + border-radius: 999px; + background: var(--bg-row); + color: var(--fg); + cursor: pointer; + white-space: nowrap; +} + +.nt-quick-chip:hover { + background: var(--bg-row-hi); + border-color: var(--accent-dim); +} + +.nt-quick-chip.is-active { + background: var(--accent); + color: var(--bg); + border-color: var(--accent); +} + +.nt-quick-empty { + font-size: 12px; + color: var(--fg-muted); +} + +/* ========== Tabs (Stations / History / Favourites) ========== */ +.nt-tabs { + border-top: 1px solid var(--bg-soft); + padding-top: 18px; +} + +.nt-tab-bar { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 12px; + border-bottom: 1px solid var(--bg-soft); +} + +.nt-tab { + font-size: 12px; + font-weight: 500; + padding: 8px 14px; + background: transparent; + color: var(--fg-muted); + border: 0; + border-bottom: 2px solid transparent; + cursor: pointer; + font-family: inherit; + text-decoration: none; +} + +.nt-tab:hover { color: var(--fg); } + +.nt-tab.is-active { + color: var(--fg); + border-bottom-color: var(--accent); +} + +.nt-tab--link { + margin-left: auto; + font-size: 16px; + padding: 6px 10px; + line-height: 1; + border-bottom: none; +} + +.nt-tab--link:hover { color: var(--accent); } + +.nt-tab-pane[hidden] { display: none; } + +.nt-pane-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.nt-pane-count { + font-size: 11px; + color: var(--fg-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.nt-pane-action { + font-size: 11px; + padding: 4px 10px; + background: var(--bg-row); + color: var(--fg-muted); + border: 1px solid var(--bg-row-hi); + border-radius: 4px; + cursor: pointer; +} + +.nt-pane-action:hover { + color: var(--danger); + border-color: var(--danger); +} + +/* ========== Track list (History + Favourites) ========== */ +.nt-track-list { + margin: 0; + padding: 0; + list-style: none; + max-height: 380px; + overflow-y: auto; + border: 1px solid var(--bg-soft); + border-radius: var(--radius); +} + +.nt-track-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px 14px; + padding: 10px 14px; + border-bottom: 1px solid var(--bg-soft); +} + +.nt-track-row:last-child { border-bottom: none; } +.nt-track-row:hover { background: var(--bg-row); } + +.nt-track-main { min-width: 0; } + +.nt-track-title { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nt-track-artist { + font-size: 12px; + color: var(--accent); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nt-track-meta { + font-size: 10px; + color: var(--fg-muted); + letter-spacing: 0.3px; + margin-top: 2px; +} + +.nt-track-controls { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; +} + +.nt-fav-btn { + background: transparent; + border: 1px solid var(--bg-row-hi); + border-radius: 999px; + width: 26px; + height: 26px; + cursor: pointer; + color: var(--fg-muted); + font-size: 14px; + line-height: 1; + padding: 0; +} + +.nt-fav-btn:hover { color: var(--cream); border-color: var(--cream); } + +.nt-fav-btn[aria-pressed="true"] { + color: var(--cream); + border-color: var(--cream); + background: rgba(244, 233, 183, 0.08); +} + +.nt-search-row { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 5px; + padding-top: 4px; +} + +.nt-search-link { + font-size: 10px; + font-weight: 600; + padding: 3px 8px; + border-radius: 3px; + text-decoration: none; + color: var(--fg); + letter-spacing: 0.3px; + border: 1px solid transparent; + text-transform: uppercase; +} + +/* Service-specific brand-leaning colours — kept muted so they don't shout */ +.nt-search-link--spotify { background: #1ed76015; color: #1ed760; border-color: #1ed76040; } +.nt-search-link--youtube { background: #ff000015; color: #ff5a5a; border-color: #ff5a5a40; } +.nt-search-link--apple { background: #fa57c115; color: #fa57c1; border-color: #fa57c140; } +.nt-search-link--bandcamp { background: #629aa915; color: #6dbdd0; border-color: #6dbdd040; } + +.nt-search-link:hover { filter: brightness(1.3); } + +.nt-track-empty { + padding: 28px 16px; + text-align: center; + color: var(--fg-muted); + font-size: 12px; +} + +/* (Legacy alias — older code referenced .nt-browse for the bottom section. + Keep here in case any styles bleed through during transition.) */ +.nt-browse { + border-top: 1px solid var(--bg-soft); + padding-top: 18px; +} + +.nt-search-wrap { + display: block; + margin-bottom: 10px; +} + +#nt-search { + width: 100%; + padding: 10px 12px; + font-size: 13px; + background: var(--bg-row); + color: var(--fg); + border: 1px solid var(--bg-row-hi); + border-radius: var(--radius); +} + +#nt-search:focus { + outline: none; + border-color: var(--accent); +} + +.nt-station-list { + margin: 0; + padding: 0; + list-style: none; + max-height: 320px; + overflow-y: auto; + border: 1px solid var(--bg-soft); + border-radius: var(--radius); +} + +.nt-station-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--bg-soft); + cursor: pointer; +} + +.nt-station-row:last-child { border-bottom: none; } + +.nt-station-row:hover { + background: var(--bg-row); +} + +.nt-station-row.is-active { + background: var(--bg-row-hi); + border-left: 3px solid var(--accent); + padding-left: 11px; +} + +.nt-station-art { + width: 32px; + height: 32px; + border-radius: 4px; + background: var(--bg-soft); + flex-shrink: 0; + object-fit: cover; +} + +.nt-station-meta { + flex: 1; + min-width: 0; +} + +.nt-station-name { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nt-station-genre { + font-size: 11px; + color: var(--fg-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ========== Footer ========== */ +.nt-footer { + max-width: var(--maxw); + margin: 0 auto; + padding: 20px 28px 28px; + text-align: center; + font-size: 11px; + color: var(--fg-muted); + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.nt-footer a { + color: var(--fg-muted); + text-decoration: none; + border-bottom: 1px dotted var(--bg-row-hi); +} + +.nt-footer a:hover { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.nt-dot { opacity: 0.5; } + +/* Scrollbar — subtle */ +.nt-station-list::-webkit-scrollbar { + width: 8px; +} +.nt-station-list::-webkit-scrollbar-thumb { + background: var(--bg-row-hi); + border-radius: 4px; +} +.nt-station-list::-webkit-scrollbar-track { + background: transparent; +} + +/* Tighter spacing on smaller windows */ +@media (max-height: 700px) { + .nt-main { padding-top: 24px; gap: 18px; } + .nt-now { padding: 12px 0 4px; } + .nt-station { font-size: 22px; } + .nt-station-list { max-height: 220px; } +} diff --git a/src/newtab/newtab.html b/src/newtab/newtab.html new file mode 100644 index 0000000..064c9e8 --- /dev/null +++ b/src/newtab/newtab.html @@ -0,0 +1,91 @@ + + + + + RangerHQ Tuner — New Tab + + + + + +
+
+ + RangerHQ Tuner +
+
+
+ --:---- +
+
+
+
+ +
+ +
+
Pick a station to begin
+
+ idle +
+ +
+ + +
+ +
+
Quick stations
+
+ Loading… +
+
+ +
+ + +
+ +
    +
    + + + + +
    + +
    + + + + + + diff --git a/src/newtab/newtab.js b/src/newtab/newtab.js new file mode 100644 index 0000000..95a8a73 --- /dev/null +++ b/src/newtab/newtab.js @@ -0,0 +1,515 @@ +// RangerHQ Tuner — New Tab Page controller. +// Replaces Chrome's default new tab with the player as a focal landing. +// Same underlying mechanics as the popup (sources/index, messages to SW, +// chrome.storage state) — but laid out for a full viewport with a +// quick-station chip row and a searchable browse list. + +import { TARGETS, TYPES } from '../lib/messages.js'; +import { listAllStations, getSource } from '../sources/index.js'; +import { + getHistory, getFavourites, + toggleFavourite, isFavourited, + clearHistory, clearFavourites, + searchUrls, formatRelativeTime, + entrySignature, +} from '../lib/history.js'; + +const els = { + // Clock + hm: document.getElementById('nt-hm'), + secs: document.getElementById('nt-secs'), + date: document.getElementById('nt-date'), + // Now playing + station: document.getElementById('nt-station'), + track: document.getElementById('nt-track'), + state: document.getElementById('nt-state'), + // Controls + play: document.getElementById('nt-play'), + volume: document.getElementById('nt-volume'), + // Stations + quickList: document.getElementById('nt-quick-list'), + search: document.getElementById('nt-search'), + list: document.getElementById('nt-station-list'), + // Tabs + panes + tabBar: document.querySelector('.nt-tab-bar'), + paneStations: document.getElementById('nt-pane-stations'), + paneHistory: document.getElementById('nt-pane-history'), + paneFavs: document.getElementById('nt-pane-favourites'), + historyList: document.getElementById('nt-history-list'), + favsList: document.getElementById('nt-favs-list'), + historyCount: document.getElementById('nt-history-count'), + favsCount: document.getElementById('nt-favs-count'), + historyClear: document.getElementById('nt-history-clear'), + favsClear: document.getElementById('nt-favs-clear'), + openOptions: document.getElementById('nt-open-options'), +}; + +const STORAGE_KEYS = { + stations: 'tuner.stationsCache', + cachedAt: 'tuner.stationsCachedAt', + currentId: 'tuner.currentStationId', + volume: 'tuner.volume', + isPlaying: 'tuner.isPlaying', +}; + +const CATALOGUE_TTL_MS = 6 * 60 * 60 * 1000; + +// Quick-pick stations. These are the SomaFM ids most people start with. +// If the catalogue is missing any (rare), they're just dropped silently. +const QUICK_IDS = [ + 'groovesalad', + 'dronezone', + 'indiepop', + 'secretagent', + 'spacestation', + 'lush', + 'deepspaceone', + 'fluid', +]; + +let stations = []; +let currentStation = null; +let playing = false; +let lastQuery = ''; +let clockTimer = null; + +init().catch(err => { + console.error('Newtab init failed:', err); + setState('error'); + els.track.textContent = `Init failed: ${err.message}`; +}); + +async function init() { + // Clock first — visible even before stations load. + tickClock(); + clockTimer = setInterval(tickClock, 1000); // 1 Hz — live seconds + + const stored = await chrome.storage.local.get(Object.values(STORAGE_KEYS)); + if (typeof stored[STORAGE_KEYS.volume] === 'number') { + els.volume.value = String(Math.round(stored[STORAGE_KEYS.volume] * 100)); + } + playing = !!stored[STORAGE_KEYS.isPlaying]; + setState(playing ? 'playing' : 'idle'); + reflectPlayButton(); + + const cached = stored[STORAGE_KEYS.stations]; + const cachedAt = stored[STORAGE_KEYS.cachedAt]; + const fresh = cached && cachedAt && (Date.now() - cachedAt < CATALOGUE_TTL_MS); + + if (fresh) { + stations = cached; + } else { + stations = await listAllStations(); + await chrome.storage.local.set({ + [STORAGE_KEYS.stations]: stations, + [STORAGE_KEYS.cachedAt]: Date.now(), + }); + } + + const currentId = stored[STORAGE_KEYS.currentId]; + currentStation = stations.find(s => s.id === currentId) || null; + renderNow(); + renderQuick(); + renderList(); + + els.play.addEventListener('click', onPlayToggle); + els.volume.addEventListener('input', onVolume); + els.search.addEventListener('input', () => { + lastQuery = els.search.value.trim().toLowerCase(); + renderList(); + }); + + // Tabs + els.tabBar.addEventListener('click', (e) => { + const tab = e.target.closest('.nt-tab'); + if (!tab) return; + if (tab.id === 'nt-open-options') { + e.preventDefault(); + chrome.runtime.openOptionsPage(); + return; + } + selectTab(tab.dataset.tab); + }); + els.historyClear.addEventListener('click', async () => { + if (!confirm('Clear all track history? Favourites will be preserved.')) return; + await clearHistory(); + await renderHistory(); + }); + els.favsClear.addEventListener('click', async () => { + if (!confirm('Clear all favourites?')) return; + await clearFavourites(); + await renderFavourites(); + await renderHistory(); // re-render history so star indicators update + }); + + // First render of track panes + await renderHistory(); + await renderFavourites(); + + // Deep-link via URL hash — popup's "History" / "Favourites" buttons + // open us with #history or #favourites. Respect that on load AND on + // hash change (e.g. user changes it manually). + applyHashTab(); + window.addEventListener('hashchange', applyHashTab); + + chrome.runtime.onMessage.addListener((msg) => { + if (msg.target !== TARGETS.POPUP) return; // SW broadcasts to "popup" as the UI bucket + if (msg.type === TYPES.STATE_CHANGED) { + setState(msg.state); + playing = (msg.state === 'playing'); + reflectPlayButton(); + chrome.storage.local.set({ [STORAGE_KEYS.isPlaying]: playing }); + } else if (msg.type === TYPES.ERROR) { + setState('error'); + els.track.textContent = `Stream error (code ${msg.code ?? '?'})`; + } else if (msg.type === TYPES.METADATA_UPDATED) { + if (msg.nowPlaying) els.track.textContent = formatNowPlaying(msg.nowPlaying); + } else if (msg.type === TYPES.TRACK_LOGGED) { + renderHistory(); + } else if (msg.type === TYPES.STORAGE_WIPED) { + // Catastrophic reset — re-read everything from scratch. + renderHistory(); + renderFavourites(); + renderQuick(); + renderList(); + } + }); + + // Cross-surface sync — if the popup (or another newtab) changes the + // current station, re-render here live without a manual reload. + chrome.storage.onChanged.addListener(async (changes, area) => { + if (area !== 'local') return; + + if (changes[STORAGE_KEYS.currentId] && stations.length) { + const newId = changes[STORAGE_KEYS.currentId].newValue; + currentStation = stations.find(s => s.id === newId) || null; + els.play.disabled = !currentStation; + renderNow(); + renderQuick(); + renderList(); + } + + if (changes[STORAGE_KEYS.isPlaying]) { + playing = !!changes[STORAGE_KEYS.isPlaying].newValue; + reflectPlayButton(); + } + + if (changes['tuner.history']) renderHistory(); + if (changes['tuner.favourites']) { + renderFavourites(); + renderHistory(); + } + }); + + els.play.disabled = !currentStation; +} + +/* ---------- Clock ---------- */ + +function tickClock() { + const now = new Date(); + const hh = String(now.getHours()).padStart(2, '0'); + const mm = String(now.getMinutes()).padStart(2, '0'); + const ss = String(now.getSeconds()).padStart(2, '0'); + els.hm.textContent = `${hh}:${mm}`; + els.secs.textContent = ss; + // Date only changes once a day; rendering the same string per tick is + // free (no DOM diff cost) so we don't bother to short-circuit it. + els.date.textContent = now.toLocaleDateString(undefined, { + weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', + }); +} + +/* ---------- Rendering ---------- */ + +function renderNow() { + if (currentStation) { + els.station.textContent = currentStation.name; + els.track.textContent = playing ? 'Now playing — fetching metadata…' : 'Ready to play'; + } else { + els.station.textContent = 'Pick a station to begin'; + els.track.textContent = '—'; + } +} + +function renderQuick() { + els.quickList.innerHTML = ''; + if (!stations.length) { + const empty = document.createElement('span'); + empty.className = 'nt-quick-empty'; + empty.textContent = 'Loading…'; + els.quickList.appendChild(empty); + return; + } + for (const shortId of QUICK_IDS) { + const fullId = `somafm:${shortId}`; + const s = stations.find(st => st.id === fullId); + if (!s) continue; + const chip = document.createElement('button'); + chip.className = 'nt-quick-chip'; + if (currentStation && currentStation.id === s.id) chip.classList.add('is-active'); + chip.type = 'button'; + chip.textContent = s.name; + chip.title = s.description || s.genre || s.name; + chip.addEventListener('click', () => onPickStation(s)); + els.quickList.appendChild(chip); + } +} + +function renderList() { + if (!stations.length) { + els.list.innerHTML = '
  • Loading stations…
  • '; + return; + } + const q = lastQuery; + const filtered = q + ? stations.filter(s => + s.name.toLowerCase().includes(q) || + s.genre.toLowerCase().includes(q) || + s.description.toLowerCase().includes(q)) + : stations; + + els.list.innerHTML = ''; + if (!filtered.length) { + const li = document.createElement('li'); + li.className = 'nt-quick-empty'; + li.style.padding = '14px'; + li.textContent = 'No matches.'; + els.list.appendChild(li); + return; + } + + for (const s of filtered) { + const li = document.createElement('li'); + li.className = 'nt-station-row'; + if (currentStation && currentStation.id === s.id) li.classList.add('is-active'); + li.setAttribute('role', 'option'); + + const img = document.createElement('img'); + img.className = 'nt-station-art'; + img.alt = ''; + img.src = s.artworkUrl || ''; + img.onerror = () => { img.style.visibility = 'hidden'; }; + + const meta = document.createElement('div'); + meta.className = 'nt-station-meta'; + + const name = document.createElement('div'); + name.className = 'nt-station-name'; + name.textContent = s.name; + + const genre = document.createElement('div'); + genre.className = 'nt-station-genre'; + genre.textContent = s.genre || s.description.slice(0, 80); + + meta.appendChild(name); + meta.appendChild(genre); + li.appendChild(img); + li.appendChild(meta); + li.addEventListener('click', () => onPickStation(s)); + + els.list.appendChild(li); + } +} + +/* ---------- Actions ---------- */ + +async function onPickStation(station) { + currentStation = station; + els.play.disabled = false; + await chrome.storage.local.set({ [STORAGE_KEYS.currentId]: station.id }); + renderNow(); + renderQuick(); + renderList(); + await playCurrent(); +} + +async function onPlayToggle() { + if (!currentStation) return; + if (playing) { + await sendToSW({ type: TYPES.PAUSE }); + } else { + await playCurrent(); + } +} + +async function playCurrent() { + if (!currentStation) return; + setState('buffering'); + try { + const source = getSource(currentStation.sourceId); + if (!source) throw new Error(`Unknown source ${currentStation.sourceId}`); + const streamUrl = await source.resolveStreamUrl(currentStation); + await sendToSW({ type: TYPES.SET_VOLUME, volume: Number(els.volume.value) / 100 }); + const stationLite = { + id: currentStation.id, + sourceId: currentStation.sourceId, + name: currentStation.name, + }; + const resp = await sendToSW({ type: TYPES.PLAY, streamUrl, station: stationLite }); + if (!resp?.ok) throw new Error(resp?.error || 'PLAY failed'); + // Offscreen will broadcast METADATA_UPDATED within ~1s. + } catch (err) { + console.error(err); + setState('error'); + els.track.textContent = `Couldn't play: ${err.message}`; + } +} + +async function onVolume() { + const v = Number(els.volume.value) / 100; + await chrome.storage.local.set({ [STORAGE_KEYS.volume]: v }); + await sendToSW({ type: TYPES.SET_VOLUME, volume: v }); +} + +/* ---------- Helpers ---------- */ + +function reflectPlayButton() { + els.play.setAttribute('aria-pressed', playing ? 'true' : 'false'); + els.play.textContent = playing ? '❚❚ Pause' : '▶ Play'; +} + +function setState(state) { + els.state.dataset.state = state; + els.state.textContent = state; +} + +function formatNowPlaying(np) { + const left = np.artist ? `${np.artist} — ` : ''; + return `${left}${np.title}`; +} + +function sendToSW(msg) { + return chrome.runtime.sendMessage({ target: TARGETS.SW, ...msg }); +} + +/* ---------- Tabs ---------- */ + +function selectTab(name) { + const map = { + stations: els.paneStations, + history: els.paneHistory, + favourites: els.paneFavs, + }; + for (const [k, pane] of Object.entries(map)) { + const active = (k === name); + pane.hidden = !active; + const tabBtn = els.tabBar.querySelector(`[data-tab="${k}"]`); + if (tabBtn) { + tabBtn.classList.toggle('is-active', active); + tabBtn.setAttribute('aria-selected', active ? 'true' : 'false'); + } + } +} + +function applyHashTab() { + const hash = (location.hash || '').replace(/^#/, '').toLowerCase(); + if (hash === 'history' || hash === 'favourites' || hash === 'stations') { + selectTab(hash); + } +} + +/* ---------- History + Favourites rendering ---------- */ + +async function renderHistory() { + const entries = await getHistory(); + els.historyCount.textContent = `${entries.length} track${entries.length === 1 ? '' : 's'}`; + if (!entries.length) { + els.historyList.innerHTML = '
  • No tracks logged yet. Play a station for a minute and they\'ll appear here.
  • '; + return; + } + // Newest first + const favs = await getFavourites(); + const favSigs = new Set(favs.map(entrySignature)); + els.historyList.innerHTML = ''; + for (let i = entries.length - 1; i >= 0; i--) { + els.historyList.appendChild(renderTrackRow(entries[i], favSigs)); + } +} + +async function renderFavourites() { + const entries = await getFavourites(); + els.favsCount.textContent = `${entries.length} favourite${entries.length === 1 ? '' : 's'}`; + if (!entries.length) { + els.favsList.innerHTML = '
  • No favourites yet. Tap the star next to a history track to add it.
  • '; + return; + } + const favSigs = new Set(entries.map(entrySignature)); + els.favsList.innerHTML = ''; + for (let i = entries.length - 1; i >= 0; i--) { + els.favsList.appendChild(renderTrackRow(entries[i], favSigs)); + } +} + +function renderTrackRow(entry, favSigs) { + const li = document.createElement('li'); + li.className = 'nt-track-row'; + + // Main column — title (big), artist (accent), meta line + const main = document.createElement('div'); + main.className = 'nt-track-main'; + + const title = document.createElement('div'); + title.className = 'nt-track-title'; + title.textContent = entry.title; + title.title = entry.title; + + const artist = document.createElement('div'); + artist.className = 'nt-track-artist'; + artist.textContent = entry.artist; + artist.title = entry.artist; + + const meta = document.createElement('div'); + meta.className = 'nt-track-meta'; + const station = entry.station || '—'; + meta.textContent = `${station} · ${formatRelativeTime(entry.at)}`; + + main.append(title, artist, meta); + + // Right column — star + (future) overflow menu + const controls = document.createElement('div'); + controls.className = 'nt-track-controls'; + + const fav = document.createElement('button'); + fav.type = 'button'; + fav.className = 'nt-fav-btn'; + const isFav = favSigs.has(entrySignature(entry)); + fav.setAttribute('aria-pressed', isFav ? 'true' : 'false'); + fav.setAttribute('aria-label', isFav ? 'Remove from favourites' : 'Add to favourites'); + fav.textContent = isFav ? '★' : '☆'; + fav.addEventListener('click', async () => { + const nowFav = await toggleFavourite(entry); + fav.setAttribute('aria-pressed', nowFav ? 'true' : 'false'); + fav.textContent = nowFav ? '★' : '☆'; + fav.setAttribute('aria-label', nowFav ? 'Remove from favourites' : 'Add to favourites'); + // Re-render favs tab so it reflects the change. + renderFavourites(); + }); + + controls.appendChild(fav); + + // Search-link row — spans full width below + const search = document.createElement('div'); + search.className = 'nt-search-row'; + const urls = searchUrls(entry.artist, entry.title); + + const linkOf = (svc, label) => { + const a = document.createElement('a'); + a.className = `nt-search-link nt-search-link--${svc}`; + a.href = urls[svc]; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + a.textContent = label; + a.title = `Search ${label} for "${entry.artist} — ${entry.title}"`; + return a; + }; + search.append( + linkOf('spotify', 'Spotify'), + linkOf('youtube', 'YouTube'), + linkOf('apple', 'Apple'), + linkOf('bandcamp', 'Bandcamp'), + ); + + li.append(main, controls, search); + return li; +} diff --git a/src/offscreen/offscreen.js b/src/offscreen/offscreen.js index 875b886..910c51e 100644 --- a/src/offscreen/offscreen.js +++ b/src/offscreen/offscreen.js @@ -1,48 +1,128 @@ // RangerHQ Tuner — Offscreen audio host. -// Owns the