475 lines
18 KiB
JavaScript
475 lines
18 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">' +
|
|
|
|
/* 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>' +
|
|
|
|
/* 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">' +
|
|
'<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 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"]');
|
|
|
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
let meta = null;
|
|
let queue = [];
|
|
let isOpen = false;
|
|
let closing = false;
|
|
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) {
|
|
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) || '');
|
|
|
|
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;
|
|
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 || '';
|
|
|
|
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));
|
|
}
|
|
|
|
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;
|
|
|
|
// Clear stale content from the previous mode before applying new state.
|
|
stopBigViz();
|
|
tracksList.innerHTML = '';
|
|
bigroomTitle.textContent = '';
|
|
bigroomArtist.textContent = '';
|
|
bigroomMeta.textContent = '';
|
|
|
|
meta = metaData;
|
|
|
|
singleMode = !!(meta && meta.singleMode);
|
|
overlay.classList.toggle('np-single', singleMode);
|
|
|
|
if (singleMode) {
|
|
bigroomTitle.textContent = meta.title || '';
|
|
bigroomArtist.textContent = meta.artist || '';
|
|
bigroomMeta.textContent = meta.description || '';
|
|
// Delay viz start so canvas has layout dimensions
|
|
setTimeout(startBigViz, 60);
|
|
} else {
|
|
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();
|
|
setCover(meta.cover || '');
|
|
|
|
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;
|
|
stopBigViz();
|
|
}, { once: true });
|
|
}
|
|
|
|
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;
|
|
if (singleMode) setTimeout(startBigViz, 60);
|
|
}
|
|
|
|
function setReopenBtn(visible) {
|
|
const btn = document.getElementById('np-reopen-btn');
|
|
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) => {
|
|
if (e.target.closest('#np-reopen-btn')) {
|
|
reopen();
|
|
return;
|
|
}
|
|
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;
|
|
}
|
|
const btn = e.target.closest('[data-nowplaying-meta]');
|
|
if (btn) {
|
|
queueFromNpBtn = 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;
|
|
}
|
|
if (e.target === overlay) close();
|
|
});
|
|
|
|
closeBtn.addEventListener('click', close);
|
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isOpen) close(); });
|
|
|
|
window.addEventListener('briTunes:queue', (e) => {
|
|
queueFromNpBtn = false;
|
|
queue = e.detail.queue || [];
|
|
// If the overlay was in single-song mode and a playlist just started, reset to playlist mode.
|
|
if (singleMode) {
|
|
singleMode = false;
|
|
overlay.classList.remove('np-single');
|
|
stopBigViz();
|
|
bigroomTitle.textContent = '';
|
|
bigroomArtist.textContent = '';
|
|
bigroomMeta.textContent = '';
|
|
}
|
|
if (isOpen) buildTrackList();
|
|
setReopenBtn(false);
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
window.addEventListener('briTunes:navigate', () => {
|
|
setReopenBtn(true);
|
|
});
|
|
|
|
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();
|
|
});
|
|
if (playerRep) {
|
|
playerRep.addEventListener('click', () => {
|
|
Promise.resolve().then(syncRepeat);
|
|
});
|
|
}
|
|
|
|
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;
|
|
});
|
|
})();
|