feat: add "Now Playing" overlay modal for playlist playback with persistent reopen button

- Introduce a full-screen "Now Playing" overlay modal with left (cover/creator) and right (track list/controls) panels
- Add CSS styles for animated cyan-themed overlay, track list, progress bar, and equalizer visualizer
- Integrate new `now-playing.js` module and update layout to include it
- Refactor playlist action buttons to include a "Now Playing" reopen button that appears when playback starts
- Dispatch `briTunes:queue` event on queue initialization for external UI synchronization
This commit is contained in:
Brian Fertig 2026-04-11 11:33:35 -06:00
parent be5857fe3b
commit a4c1595eaa
5 changed files with 553 additions and 10 deletions

View File

@ -175,6 +175,37 @@ a:hover { color: #fff; text-shadow: var(--glow-cyan); text-decoration: none; }
.btn-link { color: var(--muted); margin-left: 0.75rem; font-size: 0.82rem; }
.btn-link:hover { color: var(--neon-magenta); text-shadow: var(--glow-magenta); }
/* Playlist action row — Play All + Shuffle All on left, Now Playing on right */
.playlist-btn-row {
display: flex; align-items: center;
justify-content: space-between; gap: 0.5rem;
flex-wrap: wrap; margin: 0.25rem 0;
}
.playlist-btn-left { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
/* "Now Playing" re-open button — filled cyan to signal active state */
.btn-nowplaying {
background: var(--neon-cyan);
color: #05060d;
border: 1px solid var(--neon-cyan);
padding: 0.55rem 1rem;
border-radius: var(--radius);
font-weight: 700;
font-size: 0.82rem;
cursor: pointer;
box-shadow: 0 0 14px rgba(0,229,255,0.6), 0 0 28px rgba(0,229,255,0.25);
transition: transform .12s, box-shadow .15s;
animation: np-btn-pulse 2.5s ease-in-out infinite;
}
.btn-nowplaying:hover {
transform: translateY(-1px);
box-shadow: 0 0 22px rgba(0,229,255,0.9), 0 0 44px rgba(0,229,255,0.4);
}
@keyframes np-btn-pulse {
0%, 100% { box-shadow: 0 0 14px rgba(0,229,255,0.6), 0 0 28px rgba(0,229,255,0.25); }
50% { box-shadow: 0 0 20px rgba(0,229,255,0.85), 0 0 40px rgba(0,229,255,0.4); }
}
button.linklike {
background: none; border: none; color: var(--neon-cyan);
padding: 0; cursor: pointer; font: inherit;
@ -606,6 +637,182 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
}
.lb-close:hover { background: rgba(255,255,255,0.15); border-color: var(--neon-cyan); }
/* ---------- Now Playing overlay ---------- */
#np-overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0);
z-index: 2000;
align-items: center; justify-content: center;
}
#np-overlay.np-open { display: flex; animation: lb-fade-in 0.25s ease forwards; }
#np-overlay.np-closing { animation: lb-fade-out 0.2s ease forwards; }
#np-modal {
position: relative; display: flex;
background: var(--bg-panel);
border: 1px solid var(--neon-violet);
border-radius: var(--radius);
box-shadow: 0 0 0 1px var(--border), 0 0 40px rgba(143,91,255,0.45);
width: min(940px, 92vw);
height: min(640px, 85vh);
overflow: hidden;
}
#np-overlay.np-open #np-modal { animation: lb-zoom-in 0.3s cubic-bezier(0.34, 1.4, 0.64, 1) forwards; }
#np-overlay.np-closing #np-modal { animation: lb-zoom-out 0.2s ease forwards; }
#np-close {
position: absolute; top: 10px; right: 10px; z-index: 10;
width: 30px; height: 30px;
background: rgba(0,0,0,0.65); border: 1px solid rgba(255,255,255,0.3);
border-radius: 4px; color: #fff; font-size: 1.1rem;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s, border-color 0.15s;
}
#np-close:hover { background: rgba(255,255,255,0.15); border-color: var(--neon-cyan); }
/* ── Left panel ──────────────────────────────────────────────────── */
#np-left {
width: 38%; flex-shrink: 0;
display: flex; flex-direction: column;
background: #000; overflow: hidden;
border-right: 1px solid var(--border);
}
/* 1:1 cover using padding-bottom trick */
#np-cover-wrap {
width: 100%; position: relative; flex-shrink: 0;
}
#np-cover-wrap::before { content: ''; display: block; padding-bottom: 100%; }
#np-cover {
position: absolute; inset: 0;
width: 100%; height: 100%; object-fit: cover;
transition: opacity 0.3s ease;
}
/* Creator info below the square cover */
#np-creator-info {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 0.4rem; padding: 0.9rem 1rem;
background: var(--bg-inset); overflow: hidden; text-align: center;
}
.np-creator-avatar {
width: 58px; height: 58px; border-radius: 50%;
object-fit: cover; border: 2px solid var(--border);
background: var(--bg-panel); flex-shrink: 0;
}
.np-creator-placeholder {
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem; font-weight: bold; color: var(--muted);
}
.np-creator-label {
font-size: 0.62rem; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.07em; margin: 0;
}
.np-creator-name { font-size: 0.82rem; color: var(--fg); font-weight: 600; margin: 0; }
/* ── Right panel ─────────────────────────────────────────────────── */
#np-right { flex: 1; display: flex; flex-direction: column; min-width: 0; }
#np-header {
padding: 0.9rem 1.25rem 0.8rem;
border-bottom: 1px solid var(--border);
background: var(--bg-inset); flex-shrink: 0;
}
#np-pl-title {
font-family: 'Orbitron', monospace;
font-size: 0.88rem; font-weight: 700; color: var(--fg);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
#np-pl-meta { font-size: 0.7rem; color: var(--muted); margin-top: 0.2rem; }
/* Track list */
#np-tracks {
list-style: none; padding: 0; margin: 0;
overflow-y: auto; flex: 1;
scrollbar-width: thin; scrollbar-color: var(--border) transparent;
}
#np-tracks::-webkit-scrollbar { width: 5px; }
#np-tracks::-webkit-scrollbar-track { background: transparent; }
#np-tracks::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.np-track {
display: flex; align-items: center; gap: 0.55rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid rgba(255,255,255,0.04);
transition: background 0.1s;
}
.np-track:hover { background: rgba(255,255,255,0.04); }
.np-track.np-active { background: rgba(0,229,255,0.07); }
/* Equalizer bars */
.np-eq {
display: none; align-items: flex-end; gap: 2px;
height: 14px; flex-shrink: 0;
}
.np-track.np-active .np-eq { display: inline-flex; }
.np-eq span {
width: 3px; background: var(--neon-cyan);
border-radius: 1px; transform-origin: bottom;
}
.np-eq span:nth-child(1) { height: 14px; animation: np-eq 0.55s ease-in-out infinite alternate; }
.np-eq span:nth-child(2) { height: 10px; animation: np-eq 0.40s ease-in-out infinite alternate; animation-delay: -0.15s; }
.np-eq span:nth-child(3) { height: 12px; animation: np-eq 0.65s ease-in-out infinite alternate; animation-delay: -0.35s; }
@keyframes np-eq { from { transform: scaleY(0.15); opacity: 0.5; } to { transform: scaleY(1); opacity: 1; } }
.np-track-num {
font-size: 0.7rem; color: var(--muted);
width: 1.5rem; text-align: right; flex-shrink: 0;
font-family: 'Orbitron', monospace;
}
.np-track.np-active .np-track-num { color: var(--neon-cyan); }
.np-track-info { flex: 1; min-width: 0; }
.np-track-title {
font-size: 0.84rem; color: var(--fg);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.np-track-artist {
font-size: 0.7rem; color: var(--muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.np-track.np-active .np-track-title { color: var(--neon-cyan); }
.np-track.np-active .np-track-artist { color: rgba(0,229,255,0.6); }
/* Bottom bar */
#np-bar { flex-shrink: 0; border-top: 1px solid var(--border); background: var(--bg-inset); }
#np-progress-wrap {
height: 4px; background: rgba(255,255,255,0.08); cursor: pointer;
transition: height 0.15s;
}
#np-progress-wrap:hover { height: 6px; }
#np-progress-fill {
height: 100%; width: 0%;
background: var(--neon-cyan);
box-shadow: var(--glow-cyan);
transition: width 0.25s linear;
}
#np-controls {
display: flex; align-items: center; gap: 0.2rem;
padding: 0.45rem 0.75rem;
}
#np-btn-playpause, #np-btn-next, #np-btn-repeat {
background: none; border: none; color: var(--muted);
cursor: pointer; font-size: 1rem; padding: 3px 6px;
border-radius: 4px; transition: color 0.15s, background 0.15s;
line-height: 1;
}
#np-btn-playpause { font-size: 1.1rem; color: var(--fg); padding: 3px 8px; }
#np-btn-playpause:hover { color: var(--neon-cyan); background: rgba(0,229,255,0.08); }
#np-btn-next:hover,
#np-btn-repeat:hover { color: var(--fg); background: rgba(255,255,255,0.06); }
#np-btn-repeat.active { color: var(--neon-cyan); text-shadow: var(--glow-cyan); }
@media (max-width: 600px) {
#np-left { display: none; }
#np-modal { width: 96vw; height: 90vh; }
}
/* ---------- Avatar ---------- */
.avatar-section { display: flex; align-items: center; gap: 1rem; }
.avatar-preview {

324
public/js/now-playing.js Normal file
View File

@ -0,0 +1,324 @@
(function () {
'use strict';
const audio = document.getElementById('player-audio');
const playerNext = document.getElementById('player-next');
const playerRep = document.getElementById('player-repeat');
const playerLike = document.getElementById('player-like');
// ── Build overlay DOM ──────────────────────────────────────────────────────
const overlay = document.createElement('div');
overlay.id = 'np-overlay';
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-label', 'Now Playing');
overlay.innerHTML =
'<div id="np-modal">' +
'<button id="np-close" aria-label="Close">\u00d7</button>' +
/* Left panel */
'<div id="np-left">' +
'<div id="np-cover-wrap"><img id="np-cover" src="" alt=""></div>' +
'<div id="np-creator-info">' +
'<div id="np-creator-avatar-wrap"></div>' +
'<p class="np-creator-label">Created by</p>' +
'<p class="np-creator-name" id="np-creator-name"></p>' +
'</div>' +
'</div>' +
/* Right panel */
'<div id="np-right">' +
'<div id="np-header">' +
'<div id="np-pl-title"></div>' +
'<div id="np-pl-meta"></div>' +
'</div>' +
'<ul id="np-tracks"></ul>' +
'<div id="np-bar">' +
'<div id="np-progress-wrap"><div id="np-progress-fill"></div></div>' +
'<div id="np-controls">' +
'<button id="np-btn-playpause" title="Play / Pause">\u25b6</button>' +
'<button id="np-btn-next" title="Next track">\u23ed</button>' +
'<button id="np-btn-repeat" title="Repeat">\u21ba</button>' +
'<button class="btn-social" data-social-action="like" data-social-type="song" data-social-id="" aria-pressed="false" title="Like" disabled>' +
'\ud83d\udc4d <span class="social-count">0</span>' +
'</button>' +
'<button class="btn-social" data-social-action="favorite" data-social-type="song" data-social-id="" aria-pressed="false" title="Favorite" disabled>' +
'\u2b50 <span class="social-count">0</span>' +
'</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
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 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"]');
// ── 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]
// ── Helpers ────────────────────────────────────────────────────────────────
function esc(str) {
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function buildTrackList() {
tracksList.innerHTML = '';
queue.forEach((track, i) => {
const li = document.createElement('li');
li.className = 'np-track';
li.dataset.trackId = track.id;
li.innerHTML =
'<span class="np-eq" aria-hidden="true"><span></span><span></span><span></span></span>' +
'<span class="np-track-num">' + (i + 1) + '</span>' +
'<span class="np-track-info">' +
'<div class="np-track-title">' + esc(track.title || '') + '</div>' +
'<div class="np-track-artist">' + esc(track.artist || '') + '</div>' +
'</span>';
tracksList.appendChild(li);
});
}
function setCover(src) {
if (!src) { coverImg.style.opacity = '0'; return; }
if (coverImg.src === src) return;
coverImg.style.opacity = '0';
coverImg.onload = () => { coverImg.style.opacity = '1'; };
coverImg.onerror = () => { coverImg.style.opacity = '0'; };
coverImg.src = src;
}
function setCurrentTrack(song) {
// 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' });
}
});
// Update bottom-bar social buttons.
const hasSong = !!song.id;
npLikeBtn.disabled = npFavBtn.disabled = !hasSong;
npLikeBtn.dataset.socialId = npFavBtn.dataset.socialId = hasSong ? song.id : '';
npLikeBtn.classList.toggle('active', !!song.userLiked);
npLikeBtn.setAttribute('aria-pressed', String(!!song.userLiked));
npFavBtn.classList.toggle('active', !!song.userFavorited);
npFavBtn.setAttribute('aria-pressed', String(!!song.userFavorited));
const lc = npLikeBtn.querySelector('.social-count');
const fc = npFavBtn.querySelector('.social-count');
if (lc) lc.textContent = song.likeCount ?? 0;
if (fc) fc.textContent = song.favoriteCount ?? 0;
}
function populateCreator(creatorData) {
creatorWrap.innerHTML = '';
if (!creatorData) {
overlay.querySelector('#np-creator-info').style.display = 'none';
return;
}
overlay.querySelector('#np-creator-info').style.display = '';
if (creatorData.avatarPath) {
const img = document.createElement('img');
img.src = '/media/' + creatorData.avatarPath;
img.alt = '';
img.className = 'np-creator-avatar';
creatorWrap.appendChild(img);
} else {
const ph = document.createElement('div');
ph.className = 'np-creator-avatar np-creator-placeholder';
ph.textContent = (creatorData.displayName || '?').charAt(0).toUpperCase();
creatorWrap.appendChild(ph);
}
creatorName.textContent = creatorData.displayName || '';
}
function syncPlayPause() {
const playing = audio && !audio.paused && !audio.ended;
npPlayPauseBtn.textContent = playing ? '\u23f8' : '\u25b6';
npPlayPauseBtn.setAttribute('aria-pressed', String(playing));
npPlayPauseBtn.title = playing ? 'Pause' : 'Play';
}
function syncRepeat() {
const on = playerRep && playerRep.classList.contains('active');
npRepeatBtn.classList.toggle('active', on);
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) {
npLikeBtn.setAttribute('data-require-login', '');
npFavBtn.setAttribute('data-require-login', '');
} else {
npLikeBtn.removeAttribute('data-require-login');
npFavBtn.removeAttribute('data-require-login');
}
}
// ── Open / close ───────────────────────────────────────────────────────────
function open(metaData) {
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(' · ');
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);
overlay.classList.remove('np-closing');
overlay.classList.add('np-open');
document.body.style.overflow = 'hidden';
isOpen = true;
}
function close() {
if (!isOpen || closing) return;
closing = true;
overlay.classList.add('np-closing');
overlay.addEventListener('animationend', function onEnd() {
overlay.removeEventListener('animationend', onEnd);
overlay.classList.remove('np-open', 'np-closing');
document.body.style.overflow = '';
isOpen = false;
closing = false;
}, { once: true });
}
// Re-show overlay without re-initialising — used by the "Now Playing" button.
function reopen() {
if (isOpen || closing || !meta) return;
syncRepeat();
const cur = window.briTunesNowPlaying;
if (cur) setCurrentTrack(cur);
overlay.classList.remove('np-closing');
overlay.classList.add('np-open');
document.body.style.overflow = 'hidden';
isOpen = true;
}
function setReopenBtn(visible) {
const btn = document.getElementById('np-reopen-btn');
if (btn) btn.style.display = visible ? '' : 'none';
}
// ── Event wiring ───────────────────────────────────────────────────────────
document.addEventListener('click', (e) => {
// Re-open button
if (e.target.closest('#np-reopen-btn')) {
reopen();
return;
}
// Play All / Shuffle All on a playlist page
const btn = e.target.closest('[data-nowplaying-meta]');
if (btn) {
queueFromNpBtn = true;
setReopenBtn(true);
try { open(JSON.parse(btn.getAttribute('data-nowplaying-meta'))); }
catch (err) { console.error('[now-playing]', err); }
return;
}
if (e.target === overlay) close();
});
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.
setReopenBtn(false);
});
// Track changes.
window.addEventListener('briTunes:play', (e) => {
if (!isOpen) return;
setCurrentTrack(e.detail);
});
// Next / Repeat — delegate to actual player bar buttons.
npPlayPauseBtn.addEventListener('click', () => {
if (!audio) return;
if (audio.paused || audio.ended) audio.play().catch(() => {});
else audio.pause();
});
if (audio) {
audio.addEventListener('play', () => { if (isOpen) syncPlayPause(); });
audio.addEventListener('pause', () => { if (isOpen) syncPlayPause(); });
}
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;
progressFill.style.width = (audio.currentTime / audio.duration * 100) + '%';
});
}
progressWrap.addEventListener('click', (e) => {
if (!audio || !audio.duration) return;
const rect = progressWrap.getBoundingClientRect();
audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
});
})();

View File

@ -77,6 +77,7 @@
if (!Array.isArray(list) || list.length === 0) return;
state.queue = list;
state.index = 0;
window.dispatchEvent(new CustomEvent('briTunes:queue', { detail: { queue: list.slice() } }));
playCurrent();
}

View File

@ -37,6 +37,7 @@
<script src="/static/js/player.js" defer></script>
<script src="/static/js/social.js" defer></script>
<script src="/static/js/lightbox.js" defer></script>
<script src="/static/js/now-playing.js" defer></script>
<script type="module" src="/static/js/bg-grid.js"></script>
<script type="module" src="/static/js/player-viz.js"></script>
<script type="module" src="/static/js/boot-sequence.js"></script>

View File

@ -23,17 +23,27 @@
aria-pressed="<%= playlist.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= playlist.favoriteCount || 0 %></span></button>
</div>
<% const npMeta = JSON.stringify({ title: playlist.title, description: playlist.description || null, cover: playlist.coverPath ? '/media/' + playlist.coverPath : null, trackCount: tracks.length, creator: creator ? { displayName: creator.displayName, avatarPath: creator.avatarPath || null } : null }); %>
<div class="playlist-btn-row">
<div class="playlist-btn-left">
<button type="button" class="btn-primary"
id="play-playlist-btn"
data-nowplaying-meta="<%= npMeta %>"
data-play-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null, userLiked: !!t.userLiked, userFavorited: !!t.userFavorited, likeCount: t.likeCount || 0, favoriteCount: t.favoriteCount || 0 }))) %>">
▶ Play all
</button>
<button type="button" class="btn-primary"
data-nowplaying-meta="<%= npMeta %>"
data-shuffle-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null, userLiked: !!t.userLiked, userFavorited: !!t.userFavorited, likeCount: t.likeCount || 0, favoriteCount: t.favoriteCount || 0 }))) %>">
⇄ Shuffle all
</button>
<a href="/playlists" class="btn-link">← Back</a>
</div>
<button type="button" id="np-reopen-btn" class="btn-nowplaying" style="display:none">
▶ Now Playing
</button>
</div>
</div>
<% if (creator) { %>
<div class="detail-creator-card">
<% if (creator.avatarPath) { %>