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);
|
||||
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; }
|
||||
.cover-fallback {
|
||||
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);
|
||||
}
|
||||
|
||||
/* Flanking 3D canvas objects — always above all overlays */
|
||||
.oo-canvas { z-index: 3100; }
|
||||
|
||||
#lb-overlay {
|
||||
display: none;
|
||||
position: fixed; inset: 0;
|
||||
|
|
@ -660,7 +671,7 @@ a.detail-creator-card:hover {
|
|||
display: none;
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0);
|
||||
z-index: 2000;
|
||||
z-index: 2600;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
#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() {
|
||||
const cur = state.queue[state.index];
|
||||
if (!cur) return;
|
||||
|
|
@ -110,12 +117,15 @@
|
|||
|
||||
window.addEventListener('briTunes:navigate', () => {
|
||||
if (window.briTunesNowPlaying) highlightNowPlaying(window.briTunesNowPlaying.id);
|
||||
highlightPlaylistCards();
|
||||
});
|
||||
|
||||
// Delegated click handler for any [data-play-song] / [data-play-playlist] button.
|
||||
document.addEventListener('click', (e) => {
|
||||
const songBtn = e.target.closest('[data-play-song]');
|
||||
if (songBtn) {
|
||||
window.briTunesActivePlaylistId = null;
|
||||
highlightPlaylistCards();
|
||||
try {
|
||||
const song = JSON.parse(songBtn.getAttribute('data-play-song'));
|
||||
playOne(song);
|
||||
|
|
@ -124,6 +134,8 @@
|
|||
}
|
||||
const listBtn = e.target.closest('[data-play-playlist]');
|
||||
if (listBtn) {
|
||||
window.briTunesActivePlaylistId = listBtn.dataset.playlistId || null;
|
||||
highlightPlaylistCards();
|
||||
try {
|
||||
const list = JSON.parse(listBtn.getAttribute('data-play-playlist'));
|
||||
playList(list);
|
||||
|
|
@ -132,6 +144,8 @@
|
|||
}
|
||||
const shuffleBtn = e.target.closest('[data-shuffle-playlist]');
|
||||
if (shuffleBtn) {
|
||||
window.briTunesActivePlaylistId = shuffleBtn.dataset.playlistId || null;
|
||||
highlightPlaylistCards();
|
||||
try {
|
||||
const list = JSON.parse(shuffleBtn.getAttribute('data-shuffle-playlist'));
|
||||
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/now-playing.js" defer></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/boot-sequence.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<% } else { %>
|
||||
<div class="grid">
|
||||
<% 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) { %>
|
||||
<img src="/media/<%= p.coverPath %>" alt="" />
|
||||
<% } else { %>
|
||||
|
|
|
|||
|
|
@ -28,11 +28,13 @@
|
|||
<div class="playlist-btn-left">
|
||||
<button type="button" class="btn-primary"
|
||||
id="play-playlist-btn"
|
||||
data-playlist-id="<%= playlist.id %>"
|
||||
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 }))) %>">
|
||||
▶ Play all
|
||||
</button>
|
||||
<button type="button" class="btn-primary"
|
||||
data-playlist-id="<%= playlist.id %>"
|
||||
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 }))) %>">
|
||||
⇄ Shuffle all
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<% } else { %>
|
||||
<div class="grid">
|
||||
<% 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) { %>
|
||||
<img src="/media/<%= p.coverPath %>" alt="" />
|
||||
<% } else { %>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
<% } else { %>
|
||||
<div class="grid">
|
||||
<% 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) { %>
|
||||
<img src="/media/<%= p.coverPath %>" alt="" />
|
||||
<% } else { %>
|
||||
|
|
@ -125,7 +125,7 @@
|
|||
<% } else { %>
|
||||
<div class="grid">
|
||||
<% 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) { %>
|
||||
<img src="/media/<%= p.coverPath %>" alt="" />
|
||||
<% } else { %>
|
||||
|
|
|
|||
Loading…
Reference in New Issue