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,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; }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>RangerHQ Tuner — New Tab</title>
|
||||
<link rel="stylesheet" href="newtab.css">
|
||||
<link rel="icon" href="../assets/icons/icon-48.png">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="nt-header">
|
||||
<div class="nt-brand">
|
||||
<img src="../assets/img/ranger.png" alt="" class="nt-helmet">
|
||||
<span class="nt-brand-name">RangerHQ Tuner</span>
|
||||
</div>
|
||||
<div class="nt-clock" aria-live="off">
|
||||
<div class="nt-time" id="nt-time">
|
||||
<span id="nt-hm">--:--</span><span class="nt-secs" id="nt-secs">--</span>
|
||||
</div>
|
||||
<div class="nt-date" id="nt-date">—</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="nt-main">
|
||||
|
||||
<section class="nt-now" aria-label="Now playing">
|
||||
<div class="nt-station" id="nt-station">Pick a station to begin</div>
|
||||
<div class="nt-track" id="nt-track">—</div>
|
||||
<span class="nt-state-pill" id="nt-state" aria-live="polite">idle</span>
|
||||
</section>
|
||||
|
||||
<section class="nt-controls" aria-label="Playback controls">
|
||||
<button id="nt-play" class="nt-btn nt-btn--primary" aria-pressed="false" disabled>▶ Play</button>
|
||||
<label class="nt-vol-wrap">
|
||||
<span class="nt-vol-label">Vol</span>
|
||||
<input type="range" id="nt-volume" min="0" max="100" value="70" aria-label="Volume">
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="nt-quick" aria-label="Quick stations">
|
||||
<div class="nt-quick-label">Quick stations</div>
|
||||
<div class="nt-quick-list" id="nt-quick-list">
|
||||
<span class="nt-quick-empty">Loading…</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="nt-tabs" aria-label="Browse">
|
||||
<div class="nt-tab-bar" role="tablist">
|
||||
<button class="nt-tab is-active" role="tab" aria-selected="true" data-tab="stations">Stations</button>
|
||||
<button class="nt-tab" role="tab" aria-selected="false" data-tab="history">History</button>
|
||||
<button class="nt-tab" role="tab" aria-selected="false" data-tab="favourites">Favourites</button>
|
||||
<a class="nt-tab nt-tab--link" href="#" id="nt-open-options" title="Open settings">⚙</a>
|
||||
</div>
|
||||
|
||||
<div class="nt-tab-pane" id="nt-pane-stations" role="tabpanel">
|
||||
<label class="nt-search-wrap">
|
||||
<input type="search" id="nt-search" placeholder="Search all stations…" aria-label="Search stations">
|
||||
</label>
|
||||
<ul id="nt-station-list" class="nt-station-list" role="listbox"></ul>
|
||||
</div>
|
||||
|
||||
<div class="nt-tab-pane" id="nt-pane-history" role="tabpanel" hidden>
|
||||
<div class="nt-pane-toolbar">
|
||||
<span class="nt-pane-count" id="nt-history-count">—</span>
|
||||
<button class="nt-pane-action" id="nt-history-clear" type="button">Clear history</button>
|
||||
</div>
|
||||
<ul id="nt-history-list" class="nt-track-list" role="list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="nt-tab-pane" id="nt-pane-favourites" role="tabpanel" hidden>
|
||||
<div class="nt-pane-toolbar">
|
||||
<span class="nt-pane-count" id="nt-favs-count">—</span>
|
||||
<button class="nt-pane-action" id="nt-favs-clear" type="button">Clear favourites</button>
|
||||
</div>
|
||||
<ul id="nt-favs-list" class="nt-track-list" role="list"></ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="nt-footer">
|
||||
<a href="https://davidtkeane.com" target="_blank" rel="noopener">davidtkeane.com</a>
|
||||
<span class="nt-dot">·</span>
|
||||
<a href="https://git.davidtkeane.com/ranger/rangerhq-tuner" target="_blank" rel="noopener">Gitea</a>
|
||||
<span class="nt-dot">·</span>
|
||||
<span>SomaFM · indie radio · no telemetry</span>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="newtab.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = '<li class="nt-quick-empty" style="padding:14px;">Loading stations…</li>';
|
||||
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 = '<li class="nt-track-empty">No tracks logged yet. Play a station for a minute and they\'ll appear here.</li>';
|
||||
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 = '<li class="nt-track-empty">No favourites yet. Tap the star next to a history track to add it.</li>';
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user