mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 03:41:40 +00:00
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:
+143
-1
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user