<style>#media-3d-canvas {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: #000;
z-index: 1; /* Make sure this is behind your Alpine UI */
}
#media-vault {
display: none;
}
.page-container {
position: relative;
z-index: 2; /* Keeps Alpine loader/UI on top */
pointer-events: none; /* Allows clicks to pass through to the 3D gallery */
}
.page-container * {
pointer-events: auto; /* Re-enables clicking for UI elements */
}</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/0.149.0/three.min.js"></script>
<script>
window.addEventListener('load', function() {
console.log("Gallery: Waiting for Alpine...");
let alpineCheck = setInterval(() => {
const el = document.querySelector('[x-data]');
if (window.Alpine && el) {
// Support both Alpine v2 and v3
const data = el.__x ? el.__x.data : (window.Alpine.$data ? window.Alpine.$data(el) : null);
if (data && data.vids && data.vids.length > 0) {
console.log("Gallery: Data found, starting 3D...");
clearInterval(alpineCheck);
init3DGallery(data);
}
}
}, 100);
function init3DGallery(alpineData) {
const container = document.getElementById('media-3d-canvas');
const vault = document.getElementById('media-vault');
let scene, camera, renderer, raycaster, mouse;
let planes = [];
let velocity = new THREE.Vector3(0, 0, 0);
let targetVel = new THREE.Vector3(0, 0, 0);
let autoVel = new THREE.Vector3(0, 0, -0.2);
let isDragging = false, interactionStarted = false;
let lastPointer = { x: 0, y: 0 };
let mediaIndexCounter = 0;
const isMobile = /Android|iPhone/i.test(navigator.userAgent);
const ACTIVE_COUNT = isMobile ? 10 : 20;
const TUNNEL_LENGTH = 1600;
const SPREAD = 700;
function init() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
scene.fog = new THREE.Fog(0x000000, 100, TUNNEL_LENGTH * 0.9);
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 3000);
camera.position.z = 500;
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
for (let i = 0; i < ACTIVE_COUNT; i++) { createPlane(i); }
const startVideos = () => {
if (interactionStarted) return;
interactionStarted = true;
planes.forEach(p => { if(p.userData.video) p.userData.video.play().catch(()=>{}) });
};
container.addEventListener('mousedown', e => {
isDragging = true;
lastPointer.x = e.clientX;
lastPointer.y = e.clientY;
startVideos();
});
window.addEventListener('mouseup', () => isDragging = false);
window.addEventListener('mousemove', onMouseMove);
container.addEventListener('touchstart', e => {
isDragging = true;
lastPointer.x = e.touches[0].clientX;
lastPointer.y = e.touches[0].clientY;
startVideos();
}, {passive: false});
container.addEventListener('touchmove', e => {
if(!isDragging) return;
const dX = e.touches[0].clientX - lastPointer.x;
const dY = e.touches[0].clientY - lastPointer.y;
targetVel.x -= dX * 0.2; targetVel.y += dY * 0.2;
lastPointer.x = e.touches[0].clientX; lastPointer.y = e.touches[0].clientY;
}, {passive: false});
container.addEventListener('touchend', () => isDragging = false);
container.addEventListener('click', onSelect);
window.addEventListener('resize', onResize);
animate();
}
function createPlane(index) {
// Using BufferGeometry for compatibility with r149
const geo = new THREE.PlaneBufferGeometry(1, 1);
// Start with a grey color so we can see them even if video is loading
const mat = new THREE.MeshBasicMaterial({ color: 0x222222, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geo, mat);
const zPos = 500 - (index * (TUNNEL_LENGTH / ACTIVE_COUNT));
mesh.position.set((Math.random()-0.5)*SPREAD, (Math.random()-0.5)*SPREAD, zPos);
scene.add(mesh);
planes.push(mesh);
recyclePlane(mesh);
}
function recyclePlane(mesh) {
const vids = alpineData.vids;
const videoData = vids[mediaIndexCounter % vids.length];
const originalIndex = mediaIndexCounter % vids.length;
mediaIndexCounter++;
if (mesh.userData.video) {
mesh.userData.video.pause();
mesh.userData.video.src = "";
mesh.userData.video.remove();
}
const v = document.createElement('video');
// PATH LOGIC FIX:
// Converts "/thumbs/123.webp" to "/videos/123.mp4"
let videoSrc = videoData.thumb
.replace('/thumbs/', '/videos/')
.replace('.webp', '.mp4');
v.src = videoSrc;
v.muted = true;
v.loop = true;
v.playsInline = true;
v.crossOrigin = "anonymous";
vault.appendChild(v);
if (interactionStarted) v.play().catch(()=>{});
const tex = new THREE.VideoTexture(v);
mesh.material.map = tex;
mesh.material.color.set(0xffffff); // Set back to white once texture is assigned
mesh.userData.video = v;
mesh.userData.index = originalIndex;
mesh.userData.baseX = (Math.random() - 0.5) * SPREAD;
mesh.userData.baseY = (Math.random() - 0.5) * SPREAD;
const unit = window.innerWidth / (isMobile ? 2.5 : 5);
mesh.scale.set(unit * 0.56, unit, 1);
}
function animate() {
requestAnimationFrame(animate);
if (!isDragging) camera.position.add(autoVel);
velocity.lerp(targetVel, 0.1);
camera.position.add(velocity);
targetVel.multiplyScalar(0.9);
const camZ = camera.position.z;
planes.forEach(mesh => {
// Infinite Loop
if (mesh.position.z > camZ + 200) {
mesh.position.z -= TUNNEL_LENGTH;
recyclePlane(mesh);
} else if (mesh.position.z < camZ - (TUNNEL_LENGTH - 200)) {
mesh.position.z += TUNNEL_LENGTH;
recyclePlane(mesh);
}
// Autoplay Center logic
const distToCam = Math.abs(mesh.position.z - (camZ - 350));
if (distToCam < 120) {
if (mesh.userData.video?.paused && interactionStarted) mesh.userData.video.play().catch(()=>{});
// Scale up the center video
const scaleFactor = isMobile ? 1.8 : 1.5;
mesh.scale.lerp(new THREE.Vector3((window.innerWidth/4)*0.56*scaleFactor, (window.innerWidth/4)*scaleFactor, 1), 0.1);
} else {
if (!mesh.userData.video?.paused) mesh.userData.video?.pause();
}
});
renderer.render(scene, camera);
}
function onMouseMove(e) {
if (!isDragging) return;
targetVel.x -= (e.clientX - lastPointer.x) * 0.1;
targetVel.y += (e.clientY - lastPointer.y) * 0.1;
lastPointer.x = e.clientX; lastPointer.y = e.clientY;
}
function onSelect(e) {
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObjects(planes);
if (hits.length > 0) {
const idx = hits[0].object.userData.index;
// Calls your Alpine openVideo function
if(alpineData.openVideo) alpineData.openVideo(idx);
}
}
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
init();
}
});
</script>