feat(highway): show feedpak author/editor credits on song load (#629)

Surface the feedpak manifest `authors` list (spec §5.4) on the highway: a credits card ("Charted by Azure") shown over the highway when a song loads, riding the count-in / a ~3s hold and dismissed when playback starts. Gated to fresh feedpak plays only (minigames, loose/archive, arrangement switches, seeks, replays excluded). Includes a 12s backstop so the overlay never lingers if playback fails to start.

Closes #628. Reviewed by Codex (3 passes, converged). Verified locally: pytest 9/9, node --test 23/23, headless-browser end-to-end.
This commit is contained in:
Byron Gamatos
2026-06-28 22:08:44 +02:00
committed by GitHub
parent 271fedda55
commit 5a0b62599d
6 changed files with 568 additions and 1 deletions
+143 -1
View File
@@ -5993,11 +5993,45 @@ let _pendingAutostart = false;
window.feedBack.on('song:ready', () => {
if (!_pendingAutostart) return;
_pendingAutostart = false;
if (!_autoplayExitEnabled() || isPlaying) return;
if (isPlaying) return;
// Feedpak contributor credits: only real feedpak plays carry authors
// (loose/archive and minigames get []), so a non-empty list is the gate.
// Shown over the highway and dismissed the moment real playback begins
// (song:play). This fresh-load path is the only place it fires —
// arrangement switches / seeks / manual replays never arm _pendingAutostart,
// and minigames never get here. Decoupled from autoplay below so credits
// show on load even when autoplay-exit is disabled.
const authors = (window.feedBack.currentSong && window.feedBack.currentSong.authors) || [];
if (authors.length) {
showSongCreditsOverlay(authors);
_creditsHideOnPlay = () => { _creditsHideOnPlay = null; hideSongCreditsOverlay(); };
window.feedBack.on('song:play', _creditsHideOnPlay, { once: true });
}
// Autoplay-exit disabled: don't auto-start. Still let the credits dwell a
// couple seconds on the freshly-loaded song, then clear them (they also
// clear early if the user manually presses Play, via _creditsHideOnPlay).
if (!_autoplayExitEnabled()) {
if (authors.length) _creditsTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_HOLD_MS);
return;
}
// "Countdown before song": play a 4-beat count-in, then start. Otherwise
// reuse the Play button's start path directly (handles HTML5 + _juceMode).
if (_countdownBeforeSongEnabled()) {
// The count-in (~2.5s) gives the credits their on-screen dwell.
Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err));
} else if (authors.length) {
// No count-in window — hold the credits a couple seconds, then start.
// _cancelCountIn() and changeArrangement() both clear _creditsTimer, so
// a teardown / arrangement switch during the hold cancels this play.
_creditsTimer = setTimeout(() => {
_creditsTimer = null;
// If playback doesn't actually start (e.g. HTML5 autoplay rejection),
// song:play never fires — clear the credits promptly rather than
// waiting for the backstop. On success the song:play listener owns it.
Promise.resolve(togglePlay())
.then(() => { if (!isPlaying) hideSongCreditsOverlay(); })
.catch((err) => { console.warn('[app] autoplay failed:', err); hideSongCreditsOverlay(); });
}, _CREDITS_HOLD_MS);
} else {
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err));
}
@@ -6395,6 +6429,11 @@ let _arrBusyTimeout = null;
async function changeArrangement(index) {
if (currentFilename) {
// Tear down any pending fresh-load credits before switching: the
// no-count-in hold timer would otherwise fire togglePlay() against the
// incoming (still-loading) arrangement. hideSongCreditsOverlay() clears
// the timer, the song:play listener, and the overlay node.
hideSongCreditsOverlay();
window.feedBack.emit('song:arrangement-changed', { filename: currentFilename, arrangement: index });
const wasPlaying = isPlaying;
const time = _audioTime();
@@ -9285,10 +9324,27 @@ let _countOverlay = null;
let _countInGen = 0;
let _countInTimer = null;
let _countInRaf = 0;
// Feedpak credits overlay (manifest `authors:`, spec §5.4): shown on the
// highway when a song is loaded, alongside the count-in. Torn down together
// with the count-in via _cancelCountIn().
let _creditsOverlay = null;
let _creditsTimer = null;
let _creditsHideOnPlay = null;
let _creditsMaxTimer = null;
const _CREDITS_HOLD_MS = 3000;
// Backstop: the overlay's primary dismiss is song:play, but playback can fail
// to start without emitting it (HTML5 autoplay rejection, JUCE start failure,
// a count-in handoff that never plays). This hard cap guarantees the credits
// never linger over the highway. Generous enough to outlast a normal count-in.
const _CREDITS_MAX_MS = 12000;
function _cancelCountIn() {
_countInGen++;
_countingIn = false;
hideCountOverlay();
// The credits overlay rides the count-in lifecycle (and its no-count-in
// hold timer), so a teardown — leaving the player, loading another song —
// must clear it too, or it lingers on the next screen.
hideSongCreditsOverlay();
if (_countInTimer) { clearTimeout(_countInTimer); _countInTimer = null; }
if (_countInRaf) { cancelAnimationFrame(_countInRaf); _countInRaf = 0; }
}
@@ -9306,6 +9362,92 @@ function hideCountOverlay() {
if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; }
}
// Map a feedpak author `role` to a friendly "<verb> by" credit line. The
// recommended vocabulary is from feedpak spec §5.4; unknown roles are
// title-cased ("foo" → "Foo by"); a missing role shows the bare name.
const _CREDIT_ROLE_VERBS = {
charter: 'Charted by',
transcriber: 'Transcribed by',
arranger: 'Arranged by',
editor: 'Edited by',
mixer: 'Mixed by',
engineer: 'Engineered by',
proofreader: 'Proofread by',
};
function _creditLineLabel(role) {
if (!role) return '';
const key = String(role).trim().toLowerCase();
if (_CREDIT_ROLE_VERBS[key]) return _CREDIT_ROLE_VERBS[key];
return key.charAt(0).toUpperCase() + key.slice(1) + ' by';
}
// Show the feedpak contributor credits over the highway. `authors` is the
// sanitized [{name, role}] list from window.feedBack.currentSong.authors.
// Anchored to the lower third (bottom-center) so it never collides with the
// vertically-centered count-in number, and pointer-events-none so it never
// intercepts clicks. No-op when there are no contributors to show.
function showSongCreditsOverlay(authors) {
if (!Array.isArray(authors) || authors.length === 0) return;
if (!_creditsOverlay) {
_creditsOverlay = document.createElement('div');
_creditsOverlay.className = 'song-credits-overlay';
document.body.appendChild(_creditsOverlay);
}
// Build via DOM + textContent — author names are untrusted pack data and
// must never be interpolated as HTML.
_creditsOverlay.replaceChildren();
const card = document.createElement('div');
card.className = 'song-credits-card';
const eyebrow = document.createElement('div');
eyebrow.className = 'song-credits-eyebrow';
eyebrow.textContent = 'Credits';
card.appendChild(eyebrow);
const title = (window.feedBack && window.feedBack.currentSong
&& window.feedBack.currentSong.title) || '';
if (title) {
const heading = document.createElement('div');
heading.className = 'song-credits-heading';
heading.textContent = title;
card.appendChild(heading);
}
for (const a of authors) {
if (!a || !a.name) continue;
const row = document.createElement('div');
row.className = 'song-credits-line';
const label = _creditLineLabel(a.role);
if (label) {
const lab = document.createElement('span');
lab.className = 'song-credits-role';
lab.textContent = label + ' ';
row.appendChild(lab);
}
const nm = document.createElement('span');
nm.className = 'song-credits-name';
nm.textContent = a.name;
row.appendChild(nm);
card.appendChild(row);
}
_creditsOverlay.appendChild(card);
// Arm the backstop so the overlay self-clears even if playback never starts
// / never emits song:play. song:play (or any teardown) clears it earlier.
if (_creditsMaxTimer) clearTimeout(_creditsMaxTimer);
_creditsMaxTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_MAX_MS);
}
function hideSongCreditsOverlay() {
if (_creditsTimer) { clearTimeout(_creditsTimer); _creditsTimer = null; }
if (_creditsMaxTimer) { clearTimeout(_creditsMaxTimer); _creditsMaxTimer = null; }
if (_creditsHideOnPlay) {
window.feedBack.off('song:play', _creditsHideOnPlay);
_creditsHideOnPlay = null;
}
if (_creditsOverlay) { _creditsOverlay.remove(); _creditsOverlay = null; }
}
async function startCountIn(opts = {}) {
if (_countingIn) return;
_countingIn = true;
+7
View File
@@ -3530,6 +3530,13 @@ function createHighway() {
// matchesArrangement on this rather than the
// arrangement name.
hasNotation: Boolean(msg.has_notation),
// Feedpak contributor credits (manifest
// `authors:`, spec §5.4): [{name, role}].
// Only real feedpak plays carry these; loose/
// archive sources and synthetic highway uses
// (minigames) get []. app.js shows a credits
// overlay on song load when this is non-empty.
authors: Array.isArray(msg.authors) ? msg.authors : [],
};
window.feedBack.emit('song:loaded', window.feedBack.currentSong);
}
+90
View File
@@ -863,3 +863,93 @@ html { scroll-behavior: smooth; }
box-shadow: 0 0 0 2px rgba(64, 128, 224, 0.7);
border-radius: 0.25rem;
}
/* Feedpak contributor credits shown over the highway when a song loads
(manifest `authors:`, spec §5.4). Anchored to the upper third so it sits
ABOVE the vertically-centered count-in number; click-through. */
.song-credits-overlay {
position: fixed;
left: 0;
right: 0;
top: 15%;
/* Above the modal layer (z-[200], incl. the "Loading audio" backdrop) and
the count-in number (z-[100]) so the credits stay prominent through the
whole load → count-in → play window. */
z-index: 205;
display: flex;
justify-content: center;
pointer-events: none;
animation: song-credits-fade-in 0.45s cubic-bezier(0.16, 1, 0.3, 1);
}
.song-credits-card {
position: relative;
min-width: 16rem;
max-width: min(90vw, 34rem);
padding: 1.4rem 2.5rem 1.5rem;
text-align: center;
background:
radial-gradient(120% 140% at 50% 0%, rgb(56 78 130 / 0.45) 0%, transparent 60%),
linear-gradient(165deg, rgb(23 30 48 / 0.92) 0%, rgb(11 15 26 / 0.94) 100%);
border: 1px solid rgb(129 140 248 / 0.28);
border-radius: 1rem;
box-shadow:
0 18px 50px rgb(0 0 0 / 0.55),
0 0 0 1px rgb(0 0 0 / 0.35),
inset 0 1px 0 rgb(255 255 255 / 0.07);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
/* Accent bar across the top edge of the card. */
.song-credits-card::before {
content: "";
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 3.25rem;
height: 3px;
border-radius: 0 0 3px 3px;
background: linear-gradient(90deg, #38bdf8, #818cf8);
box-shadow: 0 0 12px rgb(99 102 241 / 0.7);
}
.song-credits-eyebrow {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: rgb(165 180 252 / 0.9);
margin-bottom: 0.4rem;
}
.song-credits-heading {
font-size: 1.3rem;
font-weight: 800;
color: #f8fafc;
margin-bottom: 0.7rem;
letter-spacing: 0.01em;
text-shadow: 0 1px 8px rgb(0 0 0 / 0.5);
}
.song-credits-line {
font-size: 1.1rem;
line-height: 1.55;
color: #e2e8f0;
}
.song-credits-role {
color: rgb(148 163 184 / 0.95);
font-weight: 500;
}
.song-credits-name {
font-weight: 700;
color: #ffffff;
}
@keyframes song-credits-fade-in {
from { opacity: 0; transform: translateY(-12px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}