feat: add big-room single-song overlay with animated visualizer

- Introduce new `#np-bigroom-view` overlay for single-song playback with centered layout and animated circular spectrum visualizer (`#np-viz-big`)
- Refactor now-playing overlay to toggle between playlist mode and big-room mode based on `meta.singleMode`
- Add real-time canvas-based frequency visualization using analyser node, exposed globally as `window.briTunesAnalyser`
- Update song/playlist pages to support single-song playback via new `data-nowplaying-meta` attribute
- Add smart "Now Playing" reopen button that only appears when matching song/playlist is active, with per-song or per-playlist matching
This commit is contained in:
Brian Fertig 2026-04-12 12:36:08 -06:00
parent 021d0694df
commit ce287886aa
5 changed files with 222 additions and 60 deletions

View File

@ -1203,6 +1203,47 @@ a.detail-creator-card:hover {
margin: 0;
}
/* ---------- Big-room single-song overlay ---------- */
#np-bigroom-view {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 0.25rem;
padding: 1.25rem 1.5rem 0;
text-align: center;
overflow: hidden;
min-height: 0;
}
.np-single #np-bigroom-view { display: flex; }
.np-single #np-btn-next { visibility: hidden; }
#np-bigroom-title {
font-family: 'Orbitron', sans-serif;
font-size: 1.2rem;
font-weight: 700;
color: var(--fg);
text-shadow: var(--glow-cyan);
line-height: 1.3;
margin: 0;
}
#np-bigroom-artist {
font-size: 0.9rem;
color: var(--muted);
margin: 0;
}
#np-bigroom-meta {
font-size: 0.7rem;
color: var(--muted);
margin: 0 0 0.5rem;
}
#np-viz-big {
width: 270px;
height: 270px;
flex-shrink: 0;
}
/* ---------- Notifications ---------- */
.notif-nav-btn {
display: inline-flex;

View File

@ -33,11 +33,25 @@
/* Right panel */
'<div id="np-right">' +
'<div id="np-header">' +
'<div id="np-pl-title"></div>' +
'<div id="np-pl-meta"></div>' +
/* Playlist mode view */
'<div id="np-playlist-view">' +
'<div id="np-header">' +
'<div id="np-pl-title"></div>' +
'<div id="np-pl-meta"></div>' +
'</div>' +
'<ul id="np-tracks"></ul>' +
'</div>' +
'<ul id="np-tracks"></ul>' +
/* Single-song / big-room view */
'<div id="np-bigroom-view">' +
'<p id="np-bigroom-title"></p>' +
'<p id="np-bigroom-artist"></p>' +
'<p id="np-bigroom-meta"></p>' +
'<canvas id="np-viz-big"></canvas>' +
'</div>' +
/* Shared controls bar */
'<div id="np-bar">' +
'<div id="np-progress-wrap"><div id="np-progress-fill"></div></div>' +
'<div id="np-controls">' +
@ -57,28 +71,103 @@
document.body.appendChild(overlay);
// Element refs
const modal = overlay.querySelector('#np-modal');
const closeBtn = overlay.querySelector('#np-close');
const coverImg = overlay.querySelector('#np-cover');
const creatorWrap = overlay.querySelector('#np-creator-avatar-wrap');
const creatorName = overlay.querySelector('#np-creator-name');
const plTitle = overlay.querySelector('#np-pl-title');
const plMeta = overlay.querySelector('#np-pl-meta');
const tracksList = overlay.querySelector('#np-tracks');
const progressWrap = overlay.querySelector('#np-progress-wrap');
const progressFill = overlay.querySelector('#np-progress-fill');
const modal = overlay.querySelector('#np-modal');
const closeBtn = overlay.querySelector('#np-close');
const coverImg = overlay.querySelector('#np-cover');
const creatorWrap = overlay.querySelector('#np-creator-avatar-wrap');
const creatorName = overlay.querySelector('#np-creator-name');
const plTitle = overlay.querySelector('#np-pl-title');
const plMeta = overlay.querySelector('#np-pl-meta');
const tracksList = overlay.querySelector('#np-tracks');
const playlistView = overlay.querySelector('#np-playlist-view');
const bigroomView = overlay.querySelector('#np-bigroom-view');
const bigroomTitle = overlay.querySelector('#np-bigroom-title');
const bigroomArtist = overlay.querySelector('#np-bigroom-artist');
const bigroomMeta = overlay.querySelector('#np-bigroom-meta');
const bigroomCanvas = overlay.querySelector('#np-viz-big');
const progressWrap = overlay.querySelector('#np-progress-wrap');
const progressFill = overlay.querySelector('#np-progress-fill');
const npPlayPauseBtn = overlay.querySelector('#np-btn-playpause');
const npNextBtn = overlay.querySelector('#np-btn-next');
const npRepeatBtn = overlay.querySelector('#np-btn-repeat');
const npLikeBtn = overlay.querySelector('.btn-social[data-social-action="like"]');
const npFavBtn = overlay.querySelector('.btn-social[data-social-action="favorite"]');
const npLikeBtn = overlay.querySelector('.btn-social[data-social-action="like"]');
const npFavBtn = overlay.querySelector('.btn-social[data-social-action="favorite"]');
// ── State ──────────────────────────────────────────────────────────────────
let meta = null;
let queue = [];
let isOpen = false;
let closing = false;
let queueFromNpBtn = false; // true when current queue was started from [data-nowplaying-meta]
let singleMode = false;
let queueFromNpBtn = false;
// ── Big-room visualizer ────────────────────────────────────────────────────
let bigVizRaf = null;
let bigFreqData = null;
function startBigViz() {
if (bigVizRaf) return;
const analyser = window.briTunesAnalyser;
if (!analyser) {
// Audio not yet hooked — retry when it becomes available
bigVizRaf = requestAnimationFrame(startBigViz);
return;
}
bigFreqData = bigFreqData || new Uint8Array(analyser.frequencyBinCount);
const ctx = bigroomCanvas.getContext('2d');
let lastW = 0, lastH = 0;
function frame() {
bigVizRaf = requestAnimationFrame(frame);
const an = window.briTunesAnalyser;
if (!an || !bigFreqData) return;
an.getByteFrequencyData(bigFreqData);
const w = bigroomCanvas.clientWidth;
const h = bigroomCanvas.clientHeight;
if (w < 1 || h < 1) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (w !== lastW || h !== lastH) {
bigroomCanvas.width = Math.round(w * dpr);
bigroomCanvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
lastW = w; lastH = h;
}
const cx = w / 2, cy = h / 2;
const innerR = Math.min(w, h) * 0.14;
const maxBarLen = Math.min(w, h) * 0.38;
const bars = 36;
const binStep = Math.floor(bigFreqData.length / bars);
ctx.clearRect(0, 0, w, h);
for (let i = 0; i < bars; i++) {
const v = bigFreqData[i * binStep] / 255;
if (v < 0.02) continue;
const angle = (i / bars) * Math.PI * 2 - Math.PI / 2;
const len = v * maxBarLen;
const x1 = cx + Math.cos(angle) * innerR;
const y1 = cy + Math.sin(angle) * innerR;
const x2 = cx + Math.cos(angle) * (innerR + len);
const y2 = cy + Math.sin(angle) * (innerR + len);
const color = i < bars * 0.25 ? 'rgba(255,43,214,1)'
: i < bars * 0.6 ? 'rgba(143,91,255,1)'
: 'rgba(0,229,255,1)';
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.shadowColor = color;
ctx.shadowBlur = 12;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
}
ctx.shadowBlur = 0;
}
bigVizRaf = requestAnimationFrame(frame);
}
function stopBigViz() {
if (bigVizRaf) { cancelAnimationFrame(bigVizRaf); bigVizRaf = null; }
}
// ── Helpers ────────────────────────────────────────────────────────────────
function esc(str) {
@ -121,14 +210,14 @@
// Update cover (fallback to playlist cover if track has none).
setCover(song.cover || (meta && meta.cover) || '');
// Highlight active row and scroll it into view.
tracksList.querySelectorAll('.np-track').forEach(li => {
const active = String(li.dataset.trackId) === String(song.id);
li.classList.toggle('np-active', active);
if (active) {
li.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
});
if (!singleMode) {
// Highlight active row and scroll it into view.
tracksList.querySelectorAll('.np-track').forEach(li => {
const active = String(li.dataset.trackId) === String(song.id);
li.classList.toggle('np-active', active);
if (active) li.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
}
// Update bottom-bar social buttons.
const hasSong = !!song.id;
@ -167,7 +256,6 @@
}
creatorName.textContent = creatorData.displayName || '';
// Make the whole creator block a link if we have a profile slug.
if (creatorData.slug) {
creatorInfo.style.cursor = 'pointer';
creatorInfo.onclick = () => { window.location.href = '/profiles/' + creatorData.slug; };
@ -190,7 +278,6 @@
npRepeatBtn.setAttribute('aria-pressed', String(on));
}
// If the user is a guest, social buttons should redirect to login.
function applyLoginGuard() {
const needsLogin = playerLike && playerLike.hasAttribute('data-require-login');
if (needsLogin) {
@ -207,24 +294,37 @@
if (closing) return;
meta = metaData;
plTitle.textContent = meta.title || '';
const parts = [];
if (meta.trackCount) parts.push(meta.trackCount + ' track' + (meta.trackCount === 1 ? '' : 's'));
if (meta.description) parts.push(meta.description);
plMeta.textContent = parts.join(' · ');
singleMode = !!(meta && meta.singleMode);
overlay.classList.toggle('np-single', singleMode);
if (singleMode) {
playlistView.hidden = true;
bigroomView.hidden = false;
bigroomTitle.textContent = meta.title || '';
bigroomArtist.textContent = meta.artist || '';
bigroomMeta.textContent = meta.description || '';
// Delay viz start so canvas has layout dimensions
setTimeout(startBigViz, 60);
} else {
playlistView.hidden = false;
bigroomView.hidden = true;
stopBigViz();
plTitle.textContent = meta.title || '';
const parts = [];
if (meta.trackCount) parts.push(meta.trackCount + ' track' + (meta.trackCount === 1 ? '' : 's'));
if (meta.description) parts.push(meta.description);
plMeta.textContent = parts.join(' · ');
if (queue.length) buildTrackList();
}
populateCreator(meta.creator || null);
applyLoginGuard();
syncRepeat();
syncPlayPause();
// Set initial cover to playlist image; briTunes:play will update it per-track.
setCover(meta.cover || '');
// Build track list if we already have the queue (briTunes:queue fires first).
if (queue.length) buildTrackList();
// Highlight whichever track is currently active in the player.
const cur = window.briTunesNowPlaying;
if (cur) setCurrentTrack(cur);
@ -244,10 +344,10 @@
document.body.style.overflow = '';
isOpen = false;
closing = false;
stopBigViz();
}, { once: true });
}
// Re-show overlay without re-initialising — used by the "Now Playing" button.
function reopen() {
if (isOpen || closing || !meta) return;
syncRepeat();
@ -257,33 +357,46 @@
overlay.classList.add('np-open');
document.body.style.overflow = 'hidden';
isOpen = true;
if (singleMode) setTimeout(startBigViz, 60);
}
function setReopenBtn(visible) {
const btn = document.getElementById('np-reopen-btn');
if (btn) btn.style.display = visible ? '' : 'none';
if (!btn) return;
if (visible) {
if (btn.dataset.matchSongId) {
// Song-detail page: only show when this exact song is playing.
const playing = window.briTunesNowPlaying;
visible = !!(playing && String(playing.id) === String(btn.dataset.matchSongId));
} else if (btn.dataset.matchPlaylistId) {
// Playlist page: only show when this exact playlist is playing.
visible = String(window.briTunesActivePlaylistId) === String(btn.dataset.matchPlaylistId);
} else {
visible = false;
}
}
btn.style.display = visible ? '' : 'none';
}
// ── Event wiring ───────────────────────────────────────────────────────────
document.addEventListener('click', (e) => {
// Re-open button
if (e.target.closest('#np-reopen-btn')) {
reopen();
return;
}
// Click on a track row in the overlay — jump to that song.
const trackRow = e.target.closest('.np-track');
if (trackRow && isOpen) {
const id = trackRow.dataset.trackId;
if (id) window.dispatchEvent(new CustomEvent('briTunes:jumpto', { detail: { id } }));
return;
}
// Play All / Shuffle All on a playlist page
const btn = e.target.closest('[data-nowplaying-meta]');
if (btn) {
queueFromNpBtn = true;
setReopenBtn(true);
// Show directly — song hasn't started yet so the match-check would fail.
const reopenBtn = document.getElementById('np-reopen-btn');
if (reopenBtn) reopenBtn.style.display = '';
try { open(JSON.parse(btn.getAttribute('data-nowplaying-meta'))); }
catch (err) { console.error('[now-playing]', err); }
return;
@ -294,31 +407,26 @@
closeBtn.addEventListener('click', close);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isOpen) close(); });
// Receive final (post-shuffle) queue from player.js.
// Fires synchronously INSIDE the player.js click handler, so queueFromNpBtn
// is still false here. The now-playing.js click handler runs next and sets it
// true when appropriate, also showing the reopen button.
window.addEventListener('briTunes:queue', (e) => {
queueFromNpBtn = false;
queue = e.detail.queue || [];
if (isOpen) buildTrackList();
// Hide the reopen button; the click handler will re-show it if this came
// from a [data-nowplaying-meta] button.
if (isOpen && !singleMode) buildTrackList();
setReopenBtn(false);
});
// Track changes.
window.addEventListener('briTunes:play', (e) => {
// Re-evaluate reopen button now that briTunesNowPlaying is updated.
setReopenBtn(true);
if (!isOpen) return;
setCurrentTrack(e.detail);
// If analyser just became available, kick off the viz
if (singleMode && !bigVizRaf) startBigViz();
});
// After client-side navigation, restore the reopen button if a queue is active.
window.addEventListener('briTunes:navigate', () => {
if (queue.length > 0) setReopenBtn(true);
setReopenBtn(true);
});
// Next / Repeat — delegate to actual player bar buttons.
npPlayPauseBtn.addEventListener('click', () => {
if (!audio) return;
if (audio.paused || audio.ended) audio.play().catch(() => {});
@ -332,16 +440,13 @@
npNextBtn.addEventListener('click', () => { if (playerNext) playerNext.click(); });
npRepeatBtn.addEventListener('click', () => {
if (playerRep) playerRep.click();
// State update will be caught by the listener below.
});
if (playerRep) {
playerRep.addEventListener('click', () => {
// Use a microtask so the class has already been toggled by player.js.
Promise.resolve().then(syncRepeat);
});
}
// Progress bar.
if (audio) {
audio.addEventListener('timeupdate', () => {
if (!isOpen || !audio.duration) return;

View File

@ -37,6 +37,7 @@ if (canvas && audio) {
src.connect(analyser);
analyser.connect(audioCtx.destination);
hooked = true;
window.briTunesAnalyser = analyser;
} catch (err) {
console.warn('[player-viz] could not hook audio:', err.message);
}

View File

@ -41,7 +41,8 @@
</button>
<a href="/playlists" class="btn-link">← Back</a>
</div>
<button type="button" id="np-reopen-btn" class="btn-nowplaying" style="display:none">
<button type="button" id="np-reopen-btn" class="btn-nowplaying" style="display:none"
data-match-playlist-id="<%= playlist.id %>">
▶ Now Playing
</button>
</div>

View File

@ -30,11 +30,25 @@
<% if (song.year) { %><%= song.year %> · <% } %>
<% if (song.durationSeconds) { %><%= Math.floor(song.durationSeconds/60) %>:<%= String(song.durationSeconds%60).padStart(2,'0') %><% } %>
</p>
<% const npMeta = {
singleMode: true,
title: song.title,
artist: song.artist,
trackCount: 1,
cover: song.coverPath ? '/media/' + song.coverPath : null,
description: [song.genre, song.year, song.durationSeconds ? Math.floor(song.durationSeconds/60) + ':' + String(song.durationSeconds%60).padStart(2,'0') : null].filter(Boolean).join(' · '),
creator: creator ? { displayName: creator.displayName, slug: creator.slug || null, avatarPath: creator.avatarPath || null } : null
}; %>
<button type="button" class="btn-play btn-primary"
data-play-song="<%= JSON.stringify({ id: song.id, title: song.title, artist: song.artist, cover: song.coverPath ? '/media/' + song.coverPath : null, userLiked: !!song.userLiked, userFavorited: !!song.userFavorited, likeCount: song.likeCount || 0, favoriteCount: song.favoriteCount || 0 }) %>">
data-play-song="<%= JSON.stringify({ id: song.id, title: song.title, artist: song.artist, cover: song.coverPath ? '/media/' + song.coverPath : null, userLiked: !!song.userLiked, userFavorited: !!song.userFavorited, likeCount: song.likeCount || 0, favoriteCount: song.favoriteCount || 0 }) %>"
data-nowplaying-meta="<%= JSON.stringify(npMeta) %>">
▶ Play
</button>
<a href="/songs" class="btn-link">← Back to songs</a>
<button type="button" id="np-reopen-btn" class="btn-nowplaying" style="display:none"
data-match-song-id="<%= song.id %>">
▶ Now Playing
</button>
<% const shareUrl = appBaseUrl + '/songs/' + song.slug; %>
<% const shareText = siteName + ': ' + song.title + ' by ' + song.artist; %>