Bri-Tunes/public/js/now-playing.js

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, '&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';
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;
});
})();