357 lines
14 KiB
JavaScript
357 lines
14 KiB
JavaScript
(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-sleeve-scene">' +
|
|
'<div id="np-cover-wrap">' +
|
|
'<img id="np-record" src="/static/images/record.png" alt="">' +
|
|
'<img id="np-cover" src="" alt="">' +
|
|
'</div>' +
|
|
'</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, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
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';
|
|
coverImg.removeAttribute('data-lightbox');
|
|
return;
|
|
}
|
|
if (coverImg.src === src) return;
|
|
coverImg.style.opacity = '0';
|
|
coverImg.onload = () => { coverImg.style.opacity = '1'; coverImg.setAttribute('data-lightbox', ''); };
|
|
coverImg.onerror = () => { coverImg.style.opacity = '0'; coverImg.removeAttribute('data-lightbox'); };
|
|
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 = '';
|
|
const creatorInfo = overlay.querySelector('#np-creator-info');
|
|
if (!creatorData) {
|
|
creatorInfo.style.display = 'none';
|
|
return;
|
|
}
|
|
creatorInfo.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 || '';
|
|
|
|
// 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; };
|
|
} else {
|
|
creatorInfo.style.cursor = '';
|
|
creatorInfo.onclick = null;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
// 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);
|
|
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);
|
|
});
|
|
|
|
// After client-side navigation, restore the reopen button if a queue is active.
|
|
window.addEventListener('briTunes:navigate', () => {
|
|
if (queue.length > 0) setReopenBtn(true);
|
|
});
|
|
|
|
// 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;
|
|
});
|
|
})();
|