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:
parent
021d0694df
commit
ce287886aa
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; %>
|
||||
|
|
|
|||
Loading…
Reference in New Issue