Skip to content

Commit 82e8bd3

Browse files
feat(sources): rebuild Relisten source picker for tape-trader detail
The v1.x source picker labelled every source either 🎤 Soundboard or 🎧 Audience N regardless of what was actually in the API payload — collapsing the Matrix / FM / Master distinctions that tape-traders specifically care about, and hiding the taper / lineage / archive.org provenance behind a single-source-at-a-time metadata block. What's new - Horizontal chip layout (wraps naturally; ~3-4 per row at default window width). Each chip shows: type badge, taper name (or em-dash for anonymous), rating + review count, BEST pill on the clear top-rated source. JC indicator moved off chips into the metadata block to keep the chip row scannable. - Type badges are colour-coded for instant pattern recognition — SBD green, AUD amber, MTX purple, FM pink. Classification reads the Relisten free-text `source` field AND the upstream_identifier archive.org slug (.sbd. / .aud. / .matrix. / .fm.), so Matrix recordings labelled `is_soundboard=true` (because they include a board feed) now correctly classify as MTX instead of being lumped with SBDs. - Default view shows top 6 sources by rating; "Show N more sources ▾" expander reveals the rest. Cornell '77 has 23 sources — vertical full-width chips for all of them ate the whole viewport. - Upgraded metadata block: 2-column grid showing taper, transferrer, lineage, duration + track-count, review count + weighted rating, jam-charts flag, taper notes, and a clickable archive.org link (gd77-05-08.sbd.hicks.4982.sbeok.shnf → archive.org/details/…). - Switching sources mid-track auto-pauses playback so the user notices the change. Previously the old source kept streaming invisibly while the new source's track list rendered. Pure helpers (testable, browser-free) - classifySource(src) → { type, label } — heuristic with priority ordering: Matrix > FM > AUD > SBD > is_soundboard fallback. "Audience cassette master" reads AUD, not MD (which doesn't exist as a distinct badge — a master is just an SBD or AUD master). - formatTaperLabel(src) — trims, filters boilerplate ("See info file", "Unknown", "n/a", "None"), truncates >40 chars. - isBestSource(src, all) — gated on rating ≥ 9 AND ≥ 10 reviews AND ≥ 0.1 above second place. Conservative on purpose — three sources within a half-point of each other don't get the badge. 20 new unit tests covering classification edge cases and BEST gating; 102 total, all passing. No new IPC, no new endpoints, no schema changes — uses fields already in /artists/{slug}/shows/{date}. No version bump — this commit lands on main but is held back from a tag so the next batch of v2.1 work (or a Reddit-feedback patch) can ship together.
1 parent ade2a8c commit 82e8bd3

4 files changed

Lines changed: 448 additions & 33 deletions

File tree

app/renderer/style.css

Lines changed: 107 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -727,36 +727,127 @@ body {
727727
.show-actions { display: flex; gap: 8px; }
728728

729729
/* ── Source tabs ───────────────────────────────── */
730-
.source-tabs {
730+
/* ── Source picker (v2.1) ────────────────────────────────────────────────
731+
* Replaces the older `.source-tabs` / `.source-tab` chips with a richer
732+
* row-style picker showing source-type badge (SBD/AUD/MTX/FM), taper
733+
* name when known, rating + review count, and BEST / JC badges.
734+
* ───────────────────────────────────────────────────────────────────── */
735+
.source-picker { margin-bottom: 14px; }
736+
.source-picker-header {
737+
display: flex; align-items: baseline; gap: 6px;
738+
font-size: 11px; color: var(--text3);
739+
margin-bottom: 8px;
740+
text-transform: uppercase; letter-spacing: 0.05em;
741+
}
742+
.source-picker-count { font-weight: 700; color: var(--text2); }
743+
.source-picker-hint { font-weight: 500; text-transform: none; letter-spacing: 0; }
744+
745+
/* Chips wrap horizontally — 6 sources usually fit in 1-2 rows. Each chip
746+
carries the at-a-glance trio: type badge + taper name + rating. Anything
747+
richer (JC, full notes, archive link) lives in the metadata block below
748+
for the active source only, so we're not duplicating dense info on
749+
every chip and wasting vertical space. */
750+
.source-chips {
731751
display: flex;
732-
gap: 8px;
733-
margin-bottom: 14px;
734752
flex-wrap: wrap;
753+
gap: 6px;
735754
}
736-
.source-tab {
737-
padding: 8px 14px;
755+
756+
.source-chip {
757+
display: inline-flex;
758+
align-items: center; gap: 8px;
759+
padding: 6px 10px;
760+
background: var(--bg3);
738761
border: 1px solid var(--border);
739762
border-radius: 8px;
740-
cursor: pointer;
741-
background: var(--bg3);
742763
color: var(--text2);
743-
font-size: 12px;
744-
font-weight: 600;
745-
transition: all 0.12s;
764+
font: inherit; font-size: 12px;
765+
cursor: pointer;
766+
transition: background 0.12s, border-color 0.12s;
767+
max-width: 320px;
768+
}
769+
.source-chip:hover { background: var(--bg4); border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
770+
.source-chip.active {
771+
background: color-mix(in srgb, var(--accent) 10%, var(--bg3));
772+
border-color: var(--accent);
773+
color: var(--text);
774+
}
775+
776+
/* Source-type badge — color-coded for instant SBD/AUD/MTX/FM scanning. */
777+
.src-badge {
778+
display: inline-block; min-width: 38px;
779+
text-align: center;
780+
font-size: 10px; font-weight: 800;
781+
letter-spacing: 0.05em;
782+
padding: 3px 6px;
783+
border-radius: 4px;
784+
color: #fff;
785+
font-variant-numeric: tabular-nums;
786+
}
787+
.src-SBD { background: #2e8b57; } /* green — board */
788+
.src-AUD { background: #c08a3a; } /* amber — audience */
789+
.src-MTX { background: #6b5bd6; } /* purple — matrix */
790+
.src-FM { background: #c4548a; } /* pink — broadcast */
791+
792+
.src-taper { font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 170px; }
793+
.src-anon { color: var(--text3); font-style: italic; }
794+
.src-rating { font-size: 11px; color: var(--text2); font-variant-numeric: tabular-nums; white-space: nowrap; }
795+
.src-reviews { color: var(--text3); margin-left: 2px; }
796+
/* JC and BEST: smaller in the horizontal layout — they're optional flair
797+
on a chip that already has type + taper + rating doing the heavy lifting. */
798+
.src-jc { font-size: 9px; font-weight: 700; color: var(--accent); }
799+
.src-best {
800+
font-size: 9px; font-weight: 800; letter-spacing: 0.05em;
801+
background: var(--accent); color: #fff;
802+
padding: 1px 5px; border-radius: 999px;
746803
}
747-
.source-tab:hover { border-color: var(--accent); color: var(--text); }
748-
.source-tab.active { background: var(--accent); border-color: var(--accent); color: #fff; }
749804

805+
.source-expand {
806+
display: block;
807+
margin-top: 6px;
808+
background: transparent; border: 0;
809+
color: var(--text3); font-size: 11px; font-weight: 600;
810+
cursor: pointer;
811+
padding: 4px 8px;
812+
}
813+
.source-expand:hover { color: var(--accent); }
814+
815+
/* Upgraded metadata block — shows the active source's provenance in a
816+
2-column grid so taper / lineage / duration / reviews stay scannable. */
750817
.source-meta {
751818
background: var(--bg4);
752819
border-radius: 8px;
753820
padding: 12px 14px;
754821
font-size: 12px;
755-
color: var(--text3);
756-
line-height: 1.7;
757-
margin-bottom: 14px;
822+
color: var(--text2);
823+
margin: 10px 0 14px;
824+
}
825+
.source-meta-header {
826+
display: flex; align-items: center; gap: 8px;
827+
margin-bottom: 10px;
828+
padding-bottom: 8px;
829+
border-bottom: 1px solid var(--border);
830+
}
831+
.source-meta-source-label { font-size: 13px; font-weight: 600; color: var(--text); }
832+
.source-meta-grid {
833+
display: grid; grid-template-columns: 1fr 1fr;
834+
gap: 6px 24px;
835+
line-height: 1.5;
836+
}
837+
.source-meta-grid .meta-wide { grid-column: 1 / -1; }
838+
.source-meta-grid .meta-k {
839+
display: inline-block;
840+
min-width: 75px;
841+
color: var(--text3); font-size: 10px;
842+
font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
843+
margin-right: 8px;
844+
}
845+
.meta-archive-link {
846+
color: var(--accent); cursor: pointer;
847+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
848+
font-size: 11px; text-decoration: none;
758849
}
759-
.source-meta strong { color: var(--text2); }
850+
.meta-archive-link:hover { text-decoration: underline; }
760851

761852
/* ── Track list ────────────────────────────────── */
762853
.set-label {

app/renderer/views-core.js

Lines changed: 115 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { lastfmArtistImage, injectArtistBio, lastfmSimilarArtists } from './last
66
import {
77
player, queueAndPlay, flatTracks, radioMode, setRadioMode,
88
setPlayerArt, showTapePickerForTrack, openCompanion, closeCompanion,
9+
audio,
910
} from './player.js';
1011
// Circular-safe: views-nugs imports from views-core, but these are only ever
1112
// called inside function bodies (event handlers / async calls), never at init.
@@ -18,6 +19,9 @@ import {
1819
trackContainsSong,
1920
normaliseSongTitle,
2021
aggregateRelistenShowsToSongs,
22+
classifySource,
23+
formatTaperLabel,
24+
isBestSource,
2125
} from '../shared/helpers.js';
2226
import {
2327
isAvailable as setlistFmAvailable,
@@ -1902,26 +1906,100 @@ export function renderShow(show, artist) {
19021906
showToast(now ? '📍 Marked as attended!' : 'Attendance removed');
19031907
});
19041908

1905-
function renderSourceArea(idx) {
1909+
// Render one source chip — used for both the default top-6 list and the
1910+
// "show all" expanded view. Pure formatting, no state mutation.
1911+
// Horizontal chip: badge + taper + rating in a single compact line. JC
1912+
// intentionally lives in the metadata block (below, active source only)
1913+
// rather than on every chip — it adds visual noise when the user is
1914+
// scanning a row of 6+ chips looking for the SBD or best AUD.
1915+
function renderSourceChip(s, i, active) {
1916+
const cls = classifySource(s);
1917+
const taper = formatTaperLabel(s);
1918+
const reviews = s.num_reviews ?? s.review_count ?? 0;
1919+
const best = isBestSource(s, sources);
1920+
return `
1921+
<button class="source-chip ${active ? 'active' : ''}" data-sidx="${i}" type="button">
1922+
<span class="src-badge src-${cls.type}" title="${esc(cls.label)}">${cls.type}</span>
1923+
<span class="src-taper">${taper ? esc(taper) : '<span class="src-anon">—</span>'}</span>
1924+
<span class="src-rating">
1925+
${s.avg_rating ? `★ ${s.avg_rating.toFixed(2)}` : ''}
1926+
${reviews ? `<span class="src-reviews">(${reviews})</span>` : ''}
1927+
</span>
1928+
${best ? '<span class="src-best">BEST</span>' : ''}
1929+
</button>`;
1930+
}
1931+
1932+
function renderSourceArea(idx, opts = {}) {
19061933
const src = sources[idx]; if (!src) return;
19071934
state.source = src;
19081935
const tracks = flatTracks(src);
19091936

1937+
// Persist "expanded" across re-renders within the same show — clicking
1938+
// a chip in the expanded view shouldn't collapse it back.
1939+
const expanded = opts.expanded ?? (renderSourceArea._expanded === true);
1940+
renderSourceArea._expanded = expanded;
1941+
1942+
// Sort by rating desc for chip display, so the top sources land first
1943+
// regardless of API order. Track the original index so the click
1944+
// handler still picks the right source.
1945+
const sortedSources = sources
1946+
.map((s, i) => ({ s, i }))
1947+
.sort((a, b) => (b.s.avg_rating ?? 0) - (a.s.avg_rating ?? 0));
1948+
const visible = expanded ? sortedSources : sortedSources.slice(0, 6);
1949+
const hidden = sortedSources.length - visible.length;
1950+
1951+
// Upgraded metadata block — shows the active source's full provenance.
1952+
// Includes the archive.org link (clickable to view the item upstream)
1953+
// and a duration/track-count summary which is useful because sources
1954+
// sometimes split sets differently from one another.
1955+
const meta = (() => {
1956+
const cls = classifySource(src);
1957+
const dur = src.duration ? `${Math.floor(src.duration/3600)}:${String(Math.floor((src.duration%3600)/60)).padStart(2,'0')}:${String(Math.floor(src.duration%60)).padStart(2,'0')}` : null;
1958+
const numTracks = (src.sets ?? []).reduce((n, s) => n + (s.tracks?.length ?? 0), 0);
1959+
const numSets = (src.sets ?? []).length;
1960+
const upstream = src.upstream_identifier;
1961+
const upstreamUrl = upstream ? `https://archive.org/details/${encodeURIComponent(upstream)}` : null;
1962+
const reviews = src.num_reviews ?? src.review_count ?? 0;
1963+
1964+
return `
1965+
<div class="source-meta">
1966+
<div class="source-meta-header">
1967+
<span class="src-badge src-${cls.type}">${cls.type}</span>
1968+
<span class="source-meta-source-label">${esc(src.source || cls.label)}</span>
1969+
</div>
1970+
<div class="source-meta-grid">
1971+
${src.taper ? `<div><span class="meta-k">Taper</span>${esc(src.taper)}</div>` : ''}
1972+
${src.transferrer? `<div><span class="meta-k">Transferrer</span>${esc(src.transferrer)}</div>` : ''}
1973+
${src.lineage ? `<div><span class="meta-k">Lineage</span>${esc(src.lineage)}</div>` : ''}
1974+
${dur ? `<div><span class="meta-k">Duration</span>${dur} · ${numTracks} tracks${numSets > 1 ? ` · ${numSets} sets` : ''}</div>` : ''}
1975+
${reviews ? `<div><span class="meta-k">Reviews</span>${reviews} · ★ ${src.avg_rating?.toFixed(2) ?? '—'}</div>` : ''}
1976+
${src.has_jamcharts ? `<div><span class="meta-k">Annotations</span>📊 Has Jam Charts</div>` : ''}
1977+
${src.taper_notes? `<div class="meta-wide"><span class="meta-k">Notes</span>${esc(src.taper_notes)}</div>` : ''}
1978+
${src.description && !src.taper_notes ? `<div class="meta-wide"><span class="meta-k">Info</span>${esc(src.description)}</div>` : ''}
1979+
${upstreamUrl ? `<div class="meta-wide"><span class="meta-k">Archive</span><a class="meta-archive-link" data-href="${esc(upstreamUrl)}">${esc(upstream)} ↗</a></div>` : ''}
1980+
</div>
1981+
</div>`;
1982+
})();
1983+
19101984
safeInnerHTML($('sourceArea'), `
1911-
<div class="source-tabs">
1912-
${sources.map((s,i)=>`
1913-
<div class="source-tab ${i===idx?'active':''}" data-sidx="${i}">
1914-
${s.is_soundboard?'🎤 Soundboard':`🎧 Audience ${i+1}`}
1915-
${s.avg_rating?` · ★${s.avg_rating.toFixed(1)}`:''}
1916-
</div>`).join('')}
1985+
<div class="source-picker">
1986+
<div class="source-picker-header">
1987+
<span class="source-picker-count">${sources.length} source${sources.length===1?'':'s'} for this show</span>
1988+
${sources.length > 1 ? `<span class="source-picker-hint">— click any to switch</span>` : ''}
1989+
</div>
1990+
<div class="source-chips">
1991+
${visible.map(({s, i}) => renderSourceChip(s, i, i === idx)).join('')}
1992+
</div>
1993+
${hidden > 0 ? `
1994+
<button class="source-expand" type="button" data-action="expand">
1995+
Show ${hidden} more source${hidden===1?'':'s'}
1996+
</button>` : ''}
1997+
${expanded && sources.length > 6 ? `
1998+
<button class="source-expand" type="button" data-action="collapse">
1999+
Show fewer ▴
2000+
</button>` : ''}
19172001
</div>
1918-
${(src.taper_notes||src.description||src.taper||src.lineage)?`
1919-
<div class="source-meta">
1920-
${src.taper ?`<strong>Taper:</strong> ${esc(src.taper)}<br>`:''}
1921-
${src.lineage ?`<strong>Lineage:</strong> ${esc(src.lineage)}<br>`:''}
1922-
${src.taper_notes ?`<strong>Notes:</strong> ${esc(src.taper_notes)}<br>`:''}
1923-
${src.description ?`<strong>Info:</strong> ${esc(src.description)}`:''}
1924-
</div>`:''}
2002+
${meta}
19252003
<div id="trackList">
19262004
${(src.sets??[]).map((set,si)=>`
19272005
${set.name?`<div class="set-label">${esc(set.name)}</div>`
@@ -1942,10 +2020,30 @@ export function renderShow(show, artist) {
19422020
if (el) { el.classList.add('playing'); el.querySelector('.track-num').textContent = '▶'; }
19432021
}
19442022

1945-
$('sourceArea').querySelectorAll('.source-tab').forEach(tab =>
1946-
tab.addEventListener('click', () => {
2023+
$('sourceArea').querySelectorAll('.source-chip').forEach(chip =>
2024+
chip.addEventListener('click', () => {
19472025
closeCompanion();
1948-
renderSourceArea(parseInt(tab.dataset.sidx));
2026+
// Pause playback when switching sources — the currently playing
2027+
// track is from the OLD source and its track list / file URL no
2028+
// longer correspond to what the user is about to see. Pausing
2029+
// surfaces the switch clearly rather than leaving the previous
2030+
// source quietly streaming in the background.
2031+
if (state.source !== sources[parseInt(chip.dataset.sidx)]) {
2032+
try { audio?.pause(); } catch { /* no audio element yet */ }
2033+
}
2034+
renderSourceArea(parseInt(chip.dataset.sidx));
2035+
}));
2036+
2037+
$('sourceArea').querySelectorAll('.source-expand').forEach(btn =>
2038+
btn.addEventListener('click', () => {
2039+
renderSourceArea(idx, { expanded: btn.dataset.action === 'expand' });
2040+
}));
2041+
2042+
$('sourceArea').querySelectorAll('.meta-archive-link').forEach(a =>
2043+
a.addEventListener('click', e => {
2044+
e.preventDefault();
2045+
const href = a.dataset.href;
2046+
if (href) window.ipc?.openUrl(href);
19492047
}));
19502048

19512049
$('sourceArea').querySelectorAll('.track-row').forEach(row =>

app/shared/helpers.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,85 @@ export function formatAffinityReason({ name, signals } = {}) {
524524
}
525525
}
526526

527+
/* ── Relisten source classification (v2.1 source picker) ─────────────────────
528+
*
529+
* Tape traders care about SBD vs AUD vs MTX vs FM in a way the v1.x source
530+
* picker didn't reflect — every audience-style recording was labelled
531+
* "🎧 Audience N" even when the source was actually a Matrix. The Relisten
532+
* API ships two reliable signals:
533+
*
534+
* 1. `source` — free-text string the taper or transferrer wrote. Common
535+
* values: "Soundboard", "SBD", "AUD", "Matrix (see notes)", "FM
536+
* broadcast", "Master cassette". Highly inconsistent capitalisation
537+
* and wording, but reliably contains a recognisable token.
538+
* 2. `upstream_identifier` — the archive.org item ID, which follows a
539+
* strong convention like `gd77-05-08.sbd.hicks.4982.sbeok.shnf` —
540+
* `.sbd.` / `.aud.` / `.matrix.` / `.mtx.` / `.fm.` segments classify
541+
* the source unambiguously.
542+
*
543+
* `is_soundboard` is only used as the safe fallback when nothing else
544+
* resolves — it's true for SBDs AND for Matrix recordings that mix in a
545+
* board feed, so by itself it conflates two distinct classes that taper
546+
* communities treat very differently.
547+
*
548+
* Returns one of:
549+
* { type: 'SBD' | 'AUD' | 'MTX' | 'FM', label: '…' }
550+
*
551+
* `MD` ("Master") deliberately isn't a separate classification — a "master"
552+
* is the original recording medium of either an SBD or AUD source, so the
553+
* token alone doesn't tell us anything meaningful for a quick-scan badge.
554+
* "Audience cassette master" should be AUD; "Soundboard master DAT" should
555+
* be SBD; bare "Master cassette" with no other qualifier falls through to
556+
* AUD (the more common case in circulation).
557+
* ────────────────────────────────────────────────────────────────────── */
558+
export function classifySource(source) {
559+
if (!source) return { type: 'AUD', label: 'Audience' };
560+
561+
const src = (source.source ?? '').toLowerCase();
562+
const up = (source.upstream_identifier ?? '').toLowerCase();
563+
const has = (re) => re.test(src) || re.test(up);
564+
565+
// Order matters: matrix BEFORE soundboard (matrices often contain "sbd"
566+
// in the description too); audience BEFORE soundboard handling so that
567+
// "audience master" reads as AUD rather than getting caught by a board
568+
// qualifier elsewhere in the same string.
569+
if (has(/\bmatrix\b|\bmtx\b|\.mtx\.|\.matrix\./)) return { type: 'MTX', label: 'Matrix' };
570+
if (has(/\bfm\b|\.fm\.|broadcast/)) return { type: 'FM', label: 'FM broadcast' };
571+
if (has(/\baud\b|audience|\.aud\./)) return { type: 'AUD', label: 'Audience' };
572+
if (has(/\bsbd\b|soundboard|\.sbd\./)) return { type: 'SBD', label: 'Soundboard' };
573+
574+
// No textual signal — fall back to the boolean flag.
575+
if (source.is_soundboard) return { type: 'SBD', label: 'Soundboard' };
576+
return { type: 'AUD', label: 'Audience' };
577+
}
578+
579+
/** Pick a human-friendly taper label for the source chip. Returns null when
580+
* the field is empty, boilerplate ("See info file"), or "Unknown" — these
581+
* add no value to a chip and just take space. Truncates long entries. */
582+
export function formatTaperLabel(source) {
583+
const raw = (source?.taper ?? '').trim();
584+
if (!raw) return null;
585+
if (/^(see info file|unknown|n\/a|none|various)\b/i.test(raw)) return null;
586+
if (raw.length > 40) return raw.slice(0, 37) + '…';
587+
return raw;
588+
}
589+
590+
/** True when the given source is *clearly* the best of the bunch — well-
591+
* rated, well-reviewed, and meaningfully better than the next contender.
592+
* Used to mark the BEST badge on the source picker. Conservative on
593+
* purpose: shows where the top two are within 0.1 of each other don't
594+
* get the badge (it'd just be misleading). */
595+
export function isBestSource(source, allSources) {
596+
if (!source || !Array.isArray(allSources) || allSources.length < 2) return false;
597+
const rating = source.avg_rating ?? 0;
598+
const reviews = source.num_reviews ?? source.review_count ?? 0;
599+
if (rating < 9 || reviews < 10) return false;
600+
const sorted = [...allSources].sort((a, b) => (b.avg_rating ?? 0) - (a.avg_rating ?? 0));
601+
if (sorted[0] !== source) return false;
602+
const second = sorted[1]?.avg_rating ?? 0;
603+
return (rating - second) >= 0.1;
604+
}
605+
527606
/* ── Semver-ish comparison (update notifier) ──────────────────────────────── */
528607

529608
/** Compare two semver-ish strings (e.g. "1.9.0" vs "1.10.0").

0 commit comments

Comments
 (0)