feat: add 3D canvas objects flanking lightbox/now-playing overlays and playlist highlighting
- Introduce dynamic 3D geometry objects (torusKnot, trefoil, hyperboloid, etc.) rendered with Three.js on both sides of the screen when lightbox or now-playing overlay is open; only active on viewports ≥1280px wide. - Add CSS styling for `.oo-canvas` (z-index 3100) and `.card.playlist-active` to highlight the currently active playlist card with neon-cyan glow effects. - Extend player.js to track `briTunesActivePlaylistId` and toggle `playlist-active` class on cards via data attributes. - Add `data-playlist-id` attributes to playlist cards in home, playlists, profile views and play buttons in playlist view for highlighting support.
This commit is contained in:
parent
26a4bc1a99
commit
561f41f0ce
|
|
@ -264,6 +264,14 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
||||||
box-shadow: 0 0 0 1px var(--neon-magenta), 0 0 24px rgba(255,43,214,0.35);
|
box-shadow: 0 0 0 1px var(--neon-magenta), 0 0 24px rgba(255,43,214,0.35);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
.card.playlist-active {
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 0 1px var(--neon-cyan), 0 0 20px rgba(0,229,255,0.3);
|
||||||
|
}
|
||||||
|
.card.playlist-active:hover {
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 0 1px var(--neon-cyan), 0 0 32px rgba(0,229,255,0.5);
|
||||||
|
}
|
||||||
.card img { width: 100%; aspect-ratio: 1 / 1; object-fit: cover; display: block; }
|
.card img { width: 100%; aspect-ratio: 1 / 1; object-fit: cover; display: block; }
|
||||||
.cover-fallback {
|
.cover-fallback {
|
||||||
aspect-ratio: 1 / 1; display: flex; align-items: center; justify-content: center;
|
aspect-ratio: 1 / 1; display: flex; align-items: center; justify-content: center;
|
||||||
|
|
@ -585,6 +593,9 @@ a.detail-creator-card:hover {
|
||||||
box-shadow: 0 0 0 3px var(--neon-cyan), var(--glow-cyan);
|
box-shadow: 0 0 0 3px var(--neon-cyan), var(--glow-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flanking 3D canvas objects — always above all overlays */
|
||||||
|
.oo-canvas { z-index: 3100; }
|
||||||
|
|
||||||
#lb-overlay {
|
#lb-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
|
|
@ -660,7 +671,7 @@ a.detail-creator-card:hover {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
background: rgba(0,0,0,0);
|
background: rgba(0,0,0,0);
|
||||||
z-index: 2000;
|
z-index: 2600;
|
||||||
align-items: center; justify-content: center;
|
align-items: center; justify-content: center;
|
||||||
}
|
}
|
||||||
#np-overlay.np-open { display: flex; animation: lb-fade-in-synthwave 0.25s ease forwards; }
|
#np-overlay.np-open { display: flex; animation: lb-fade-in-synthwave 0.25s ease forwards; }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
const POOL = ['torusKnot','icosahedron','hyperboloid','mobius','globe','gyroscope','trefoil','saturn'];
|
||||||
|
const COLORS = [0x00e5ff, 0x8f5bff, 0xff2bd6];
|
||||||
|
const MIN_VW = 1280; // viewport width below which we hide
|
||||||
|
const FADE = 800; // ms — CSS opacity transition duration
|
||||||
|
const cycleMs = () => 20000 + Math.random() * 10000; // 20–30 s
|
||||||
|
|
||||||
|
// Calculate canvas size and edge offset so the object center sits exactly
|
||||||
|
// halfway between the screen edge and the lightbox edge.
|
||||||
|
function getSizing() {
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const modal = Math.min(940, 0.92 * vw); // mirrors #np-modal width rule
|
||||||
|
const sideWidth = (vw - modal) / 2; // available space each side
|
||||||
|
const size = Math.min(480, Math.floor(sideWidth * 1.4));
|
||||||
|
const offset = Math.max(8, Math.floor(sideWidth / 2 - size / 2));
|
||||||
|
return { size, offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
||||||
|
|
||||||
|
// ── Geometry builders ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildObject(name, color) {
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ color, wireframe: true });
|
||||||
|
const lmat = () => new THREE.LineBasicMaterial({ color });
|
||||||
|
const g = new THREE.Group();
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
|
||||||
|
case 'torusKnot':
|
||||||
|
g.add(new THREE.Mesh(new THREE.TorusKnotGeometry(0.8, 0.28, 120, 16), mat));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'trefoil':
|
||||||
|
g.add(new THREE.Mesh(new THREE.TorusKnotGeometry(0.8, 0.28, 120, 16, 2, 3), mat));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'icosahedron':
|
||||||
|
g.add(new THREE.Mesh(new THREE.IcosahedronGeometry(1, 1), mat));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'globe':
|
||||||
|
g.add(new THREE.Mesh(new THREE.SphereGeometry(1, 18, 12), mat));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'hyperboloid': {
|
||||||
|
const N = 24;
|
||||||
|
const struts = [], topRing = [], botRing = [];
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const a0 = (i / N) * Math.PI * 2;
|
||||||
|
const a1 = ((i + 0.5) / N) * Math.PI * 2;
|
||||||
|
struts.push(Math.cos(a0), 1, Math.sin(a0), Math.cos(a1), -1, Math.sin(a1));
|
||||||
|
topRing.push(Math.cos(a0), 1, Math.sin(a0));
|
||||||
|
botRing.push(Math.cos(a1), -1, Math.sin(a1));
|
||||||
|
}
|
||||||
|
// close rings
|
||||||
|
topRing.push(topRing[0], topRing[1], topRing[2]);
|
||||||
|
botRing.push(botRing[0], botRing[1], botRing[2]);
|
||||||
|
|
||||||
|
const sg = new THREE.BufferGeometry();
|
||||||
|
sg.setAttribute('position', new THREE.Float32BufferAttribute(struts, 3));
|
||||||
|
g.add(new THREE.LineSegments(sg, lmat()));
|
||||||
|
|
||||||
|
const tg = new THREE.BufferGeometry();
|
||||||
|
tg.setAttribute('position', new THREE.Float32BufferAttribute(topRing, 3));
|
||||||
|
g.add(new THREE.Line(tg, lmat()));
|
||||||
|
|
||||||
|
const bg = new THREE.BufferGeometry();
|
||||||
|
bg.setAttribute('position', new THREE.Float32BufferAttribute(botRing, 3));
|
||||||
|
g.add(new THREE.Line(bg, lmat()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'mobius': {
|
||||||
|
const uSeg = 80, vSeg = 8;
|
||||||
|
const verts = [], idx = [];
|
||||||
|
for (let ui = 0; ui <= uSeg; ui++) {
|
||||||
|
const u = (ui / uSeg) * Math.PI * 2;
|
||||||
|
for (let vi = 0; vi <= vSeg; vi++) {
|
||||||
|
const v = (vi / vSeg - 0.5) * 0.8;
|
||||||
|
verts.push(
|
||||||
|
(1 + v * Math.cos(u / 2)) * Math.cos(u),
|
||||||
|
(1 + v * Math.cos(u / 2)) * Math.sin(u),
|
||||||
|
v * Math.sin(u / 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let ui = 0; ui < uSeg; ui++) {
|
||||||
|
for (let vi = 0; vi < vSeg; vi++) {
|
||||||
|
const a = ui * (vSeg + 1) + vi;
|
||||||
|
const b = (ui + 1) * (vSeg + 1) + vi;
|
||||||
|
const c = b + 1, d = a + 1;
|
||||||
|
idx.push(a, b, c, a, c, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
|
||||||
|
geo.setIndex(idx);
|
||||||
|
g.add(new THREE.Mesh(geo, mat));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'gyroscope': {
|
||||||
|
const ringGeo = new THREE.TorusGeometry(1, 0.03, 6, 64);
|
||||||
|
const speeds = [
|
||||||
|
{ rx: 0, ry: 0, rz: 0, ax: 0, ay: 0.009, az: 0 },
|
||||||
|
{ rx: Math.PI / 2, ry: 0, rz: 0, ax: 0.006,ay: 0, az: 0 },
|
||||||
|
{ rx: Math.PI / 4, ry: Math.PI / 4, rz: 0, ax: 0, ay: 0, az: 0.012 },
|
||||||
|
];
|
||||||
|
speeds.forEach(s => {
|
||||||
|
const ring = new THREE.Mesh(ringGeo, mat.clone());
|
||||||
|
ring.rotation.set(s.rx, s.ry, s.rz);
|
||||||
|
ring.userData = { ax: s.ax, ay: s.ay, az: s.az };
|
||||||
|
g.add(ring);
|
||||||
|
});
|
||||||
|
g.userData.isGyroscope = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'saturn': {
|
||||||
|
g.add(new THREE.Mesh(new THREE.SphereGeometry(0.55, 16, 12), mat.clone()));
|
||||||
|
const ring = new THREE.Mesh(new THREE.TorusGeometry(1.05, 0.04, 6, 64), mat.clone());
|
||||||
|
ring.rotation.x = Math.PI * 0.28;
|
||||||
|
g.add(ring);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.scale.setScalar(1.1);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SideCanvas ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SideCanvas {
|
||||||
|
constructor(side) {
|
||||||
|
this.side = side;
|
||||||
|
this.alive = false;
|
||||||
|
this.animId = null;
|
||||||
|
this.timer = null;
|
||||||
|
this.object = null;
|
||||||
|
this.canvas = document.createElement('canvas');
|
||||||
|
this.scene = new THREE.Scene();
|
||||||
|
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
|
||||||
|
this.camera.position.z = 4.2; // far enough that rotating objects don't clip
|
||||||
|
this.renderer = null; // created in mount() once size is known
|
||||||
|
}
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
this.alive = true;
|
||||||
|
const { size, offset } = getSizing();
|
||||||
|
|
||||||
|
this.canvas.width = size;
|
||||||
|
this.canvas.height = size;
|
||||||
|
this.canvas.className = 'oo-canvas'; // z-index managed by CSS sibling rule
|
||||||
|
this.canvas.style.cssText = [
|
||||||
|
'position:fixed',
|
||||||
|
'top:50%',
|
||||||
|
'transform:translateY(-50%)',
|
||||||
|
this.side === 'left' ? `left:${offset}px` : `right:${offset}px`,
|
||||||
|
`width:${size}px`,
|
||||||
|
`height:${size}px`,
|
||||||
|
'pointer-events:none',
|
||||||
|
'opacity:0',
|
||||||
|
`transition:opacity ${FADE}ms ease`,
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, alpha: true, antialias: true });
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
this.renderer.setSize(size, size);
|
||||||
|
this.renderer.setClearColor(0x000000, 0);
|
||||||
|
|
||||||
|
document.body.appendChild(this.canvas);
|
||||||
|
this._spawn();
|
||||||
|
this._tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
unmount() {
|
||||||
|
this.alive = false;
|
||||||
|
cancelAnimationFrame(this.animId);
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.canvas.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.canvas.remove();
|
||||||
|
this.renderer.dispose();
|
||||||
|
}, FADE);
|
||||||
|
}
|
||||||
|
|
||||||
|
_spawn() {
|
||||||
|
if (!this.alive) return;
|
||||||
|
if (this.object) { this.scene.remove(this.object); this.object = null; }
|
||||||
|
|
||||||
|
this.object = buildObject(pick(POOL), pick(COLORS));
|
||||||
|
// Give each side a slightly different initial rotation so they don't mirror each other
|
||||||
|
this.object.rotation.y = Math.random() * Math.PI * 2;
|
||||||
|
this.scene.add(this.object);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => { this.canvas.style.opacity = '1'; });
|
||||||
|
this.timer = setTimeout(() => this._cycle(), cycleMs());
|
||||||
|
}
|
||||||
|
|
||||||
|
_cycle() {
|
||||||
|
if (!this.alive) return;
|
||||||
|
this.canvas.style.opacity = '0';
|
||||||
|
setTimeout(() => { if (this.alive) this._spawn(); }, FADE);
|
||||||
|
}
|
||||||
|
|
||||||
|
_tick() {
|
||||||
|
if (!this.alive) return;
|
||||||
|
this.animId = requestAnimationFrame(() => this._tick());
|
||||||
|
|
||||||
|
if (this.object) {
|
||||||
|
if (this.object.userData.isGyroscope) {
|
||||||
|
this.object.children.forEach(ring => {
|
||||||
|
ring.rotation.x += ring.userData.ax;
|
||||||
|
ring.rotation.y += ring.userData.ay;
|
||||||
|
ring.rotation.z += ring.userData.az;
|
||||||
|
});
|
||||||
|
this.object.rotation.y += 0.002;
|
||||||
|
} else {
|
||||||
|
this.object.rotation.y += 0.005;
|
||||||
|
this.object.rotation.x += 0.003;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Orchestration ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let left = null, right = null;
|
||||||
|
|
||||||
|
function isWide() { return window.innerWidth >= MIN_VW; }
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (left || !isWide()) return;
|
||||||
|
left = new SideCanvas('left');
|
||||||
|
right = new SideCanvas('right');
|
||||||
|
left.mount();
|
||||||
|
right.mount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!left) return;
|
||||||
|
left.unmount();
|
||||||
|
right.unmount();
|
||||||
|
left = right = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const lb = document.getElementById('lb-overlay');
|
||||||
|
const np = document.getElementById('np-overlay');
|
||||||
|
if (!lb || !np) { requestAnimationFrame(init); return; }
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
const anyOpen = lb.classList.contains('lb-open') || np.classList.contains('np-open');
|
||||||
|
if (anyOpen && !left) open();
|
||||||
|
if (!anyOpen && left) close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const obs = new MutationObserver(check);
|
||||||
|
obs.observe(lb, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
obs.observe(np, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => { if (!isWide() && left) close(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
@ -55,6 +55,13 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function highlightPlaylistCards() {
|
||||||
|
const id = window.briTunesActivePlaylistId;
|
||||||
|
document.querySelectorAll('[data-playlist-id]').forEach(el => {
|
||||||
|
el.classList.toggle('playlist-active', !!id && String(el.dataset.playlistId) === String(id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function playCurrent() {
|
function playCurrent() {
|
||||||
const cur = state.queue[state.index];
|
const cur = state.queue[state.index];
|
||||||
if (!cur) return;
|
if (!cur) return;
|
||||||
|
|
@ -110,12 +117,15 @@
|
||||||
|
|
||||||
window.addEventListener('briTunes:navigate', () => {
|
window.addEventListener('briTunes:navigate', () => {
|
||||||
if (window.briTunesNowPlaying) highlightNowPlaying(window.briTunesNowPlaying.id);
|
if (window.briTunesNowPlaying) highlightNowPlaying(window.briTunesNowPlaying.id);
|
||||||
|
highlightPlaylistCards();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delegated click handler for any [data-play-song] / [data-play-playlist] button.
|
// Delegated click handler for any [data-play-song] / [data-play-playlist] button.
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
const songBtn = e.target.closest('[data-play-song]');
|
const songBtn = e.target.closest('[data-play-song]');
|
||||||
if (songBtn) {
|
if (songBtn) {
|
||||||
|
window.briTunesActivePlaylistId = null;
|
||||||
|
highlightPlaylistCards();
|
||||||
try {
|
try {
|
||||||
const song = JSON.parse(songBtn.getAttribute('data-play-song'));
|
const song = JSON.parse(songBtn.getAttribute('data-play-song'));
|
||||||
playOne(song);
|
playOne(song);
|
||||||
|
|
@ -124,6 +134,8 @@
|
||||||
}
|
}
|
||||||
const listBtn = e.target.closest('[data-play-playlist]');
|
const listBtn = e.target.closest('[data-play-playlist]');
|
||||||
if (listBtn) {
|
if (listBtn) {
|
||||||
|
window.briTunesActivePlaylistId = listBtn.dataset.playlistId || null;
|
||||||
|
highlightPlaylistCards();
|
||||||
try {
|
try {
|
||||||
const list = JSON.parse(listBtn.getAttribute('data-play-playlist'));
|
const list = JSON.parse(listBtn.getAttribute('data-play-playlist'));
|
||||||
playList(list);
|
playList(list);
|
||||||
|
|
@ -132,6 +144,8 @@
|
||||||
}
|
}
|
||||||
const shuffleBtn = e.target.closest('[data-shuffle-playlist]');
|
const shuffleBtn = e.target.closest('[data-shuffle-playlist]');
|
||||||
if (shuffleBtn) {
|
if (shuffleBtn) {
|
||||||
|
window.briTunesActivePlaylistId = shuffleBtn.dataset.playlistId || null;
|
||||||
|
highlightPlaylistCards();
|
||||||
try {
|
try {
|
||||||
const list = JSON.parse(shuffleBtn.getAttribute('data-shuffle-playlist'));
|
const list = JSON.parse(shuffleBtn.getAttribute('data-shuffle-playlist'));
|
||||||
for (let i = list.length - 1; i > 0; i--) {
|
for (let i = list.length - 1; i > 0; i--) {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
<script src="/static/js/lightbox.js" defer></script>
|
<script src="/static/js/lightbox.js" defer></script>
|
||||||
<script src="/static/js/now-playing.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/bg-grid.js"></script>
|
||||||
|
<script type="module" src="/static/js/overlay-objects.js"></script>
|
||||||
<script type="module" src="/static/js/player-viz.js"></script>
|
<script type="module" src="/static/js/player-viz.js"></script>
|
||||||
<script type="module" src="/static/js/boot-sequence.js"></script>
|
<script type="module" src="/static/js/boot-sequence.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<% featured.forEach((p) => { %>
|
<% featured.forEach((p) => { %>
|
||||||
<a class="card" href="/playlists/<%= p.slug %>">
|
<a class="card" href="/playlists/<%= p.slug %>" data-playlist-id="<%= p.id %>">
|
||||||
<% if (p.coverPath) { %>
|
<% if (p.coverPath) { %>
|
||||||
<img src="/media/<%= p.coverPath %>" alt="" />
|
<img src="/media/<%= p.coverPath %>" alt="" />
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,13 @@
|
||||||
<div class="playlist-btn-left">
|
<div class="playlist-btn-left">
|
||||||
<button type="button" class="btn-primary"
|
<button type="button" class="btn-primary"
|
||||||
id="play-playlist-btn"
|
id="play-playlist-btn"
|
||||||
|
data-playlist-id="<%= playlist.id %>"
|
||||||
data-nowplaying-meta="<%= npMeta %>"
|
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 }))) %>">
|
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
|
▶ Play all
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-primary"
|
<button type="button" class="btn-primary"
|
||||||
|
data-playlist-id="<%= playlist.id %>"
|
||||||
data-nowplaying-meta="<%= npMeta %>"
|
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 }))) %>">
|
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
|
⇄ Shuffle all
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<% playlists.forEach((p) => { %>
|
<% playlists.forEach((p) => { %>
|
||||||
<a class="card" href="/playlists/<%= p.slug %>">
|
<a class="card" href="/playlists/<%= p.slug %>" data-playlist-id="<%= p.id %>">
|
||||||
<% if (p.coverPath) { %>
|
<% if (p.coverPath) { %>
|
||||||
<img src="/media/<%= p.coverPath %>" alt="" />
|
<img src="/media/<%= p.coverPath %>" alt="" />
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<% userPlaylists.forEach((p) => { %>
|
<% userPlaylists.forEach((p) => { %>
|
||||||
<a class="card" href="/playlists/<%= p.slug %>">
|
<a class="card" href="/playlists/<%= p.slug %>" data-playlist-id="<%= p.id %>">
|
||||||
<% if (p.coverPath) { %>
|
<% if (p.coverPath) { %>
|
||||||
<img src="/media/<%= p.coverPath %>" alt="" />
|
<img src="/media/<%= p.coverPath %>" alt="" />
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
|
|
@ -125,7 +125,7 @@
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<% likedPlaylists.forEach((p) => { %>
|
<% likedPlaylists.forEach((p) => { %>
|
||||||
<a class="card" href="/playlists/<%= p.slug %>">
|
<a class="card" href="/playlists/<%= p.slug %>" data-playlist-id="<%= p.id %>">
|
||||||
<% if (p.coverPath) { %>
|
<% if (p.coverPath) { %>
|
||||||
<img src="/media/<%= p.coverPath %>" alt="" />
|
<img src="/media/<%= p.coverPath %>" alt="" />
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue