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:
Brian Fertig 2026-04-11 15:17:51 -06:00
parent 26a4bc1a99
commit 561f41f0ce
8 changed files with 303 additions and 5 deletions

View File

@ -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; }

View File

@ -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();

View File

@ -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--) {

View File

@ -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>

View File

@ -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 { %>

View File

@ -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

View File

@ -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 { %>

View File

@ -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 { %>