436 lines
17 KiB
HTML
436 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>3D CRT Television Model (Video Player)</title>
|
|
<!-- Load Tailwind CSS -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<!-- Load Three.js -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
|
<style>
|
|
/* Apply Inter font and ensure full viewport size */
|
|
html, body {
|
|
margin: 0;
|
|
padding: 0;
|
|
height: 100%;
|
|
font-family: 'Inter', sans-serif;
|
|
overflow: hidden; /* Prevent scrolling */
|
|
}
|
|
/* Style for the canvas container */
|
|
#tv-container {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
/* The canvas itself will be appended here by three.js */
|
|
}
|
|
/* Custom styling for the file input button */
|
|
#controls input[type="file"] {
|
|
display: none;
|
|
}
|
|
#controls label {
|
|
cursor: pointer;
|
|
padding: 0.5rem 1rem;
|
|
background-color: #ef4444; /* red-500 */
|
|
color: white;
|
|
border-radius: 0.375rem; /* rounded-md */
|
|
font-weight: 600;
|
|
transition: background-color 0.2s;
|
|
box-shadow: 0 4px #b91c1c; /* darker red for 3D press effect */
|
|
user-select: none;
|
|
}
|
|
#controls label:hover {
|
|
background-color: #dc2626; /* red-600 */
|
|
}
|
|
#controls label:active {
|
|
box-shadow: 0 1px #b91c1c;
|
|
transform: translateY(3px);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="bg-gray-900 text-gray-100 antialiased">
|
|
|
|
<!-- Header -->
|
|
<header class="absolute top-0 left-0 right-0 p-4 text-center z-10">
|
|
<h1 class="text-3xl font-extrabold text-red-500">Nostalgic CRT Display (Video Player)</h1>
|
|
<p class="text-sm text-gray-400 mt-1">Click and drag to rotate the bulky set. Load a video to replace the static!</p>
|
|
</header>
|
|
|
|
<!-- Controls (Load Button) -->
|
|
<div id="controls" class="absolute bottom-4 left-0 right-0 p-4 flex justify-center z-10">
|
|
<input type="file" id="videoFileInput" accept="video/*">
|
|
<label for="videoFileInput" id="loadVideoLabel">
|
|
Load Video File
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Hidden Video Element for decoding the video file -->
|
|
<video id="videoElement" class="hidden" loop muted playsinline></video>
|
|
|
|
<!-- 3D Canvas Container -->
|
|
<div id="tv-container">
|
|
<!-- Three.js will inject the canvas here -->
|
|
</div>
|
|
|
|
<script>
|
|
// --- Global Variables ---
|
|
let scene, camera, renderer;
|
|
let tvModel;
|
|
let isDragging = false;
|
|
let previousMousePosition = { x: 0, y: 0 };
|
|
let screenMesh; // Reference to the screen mesh for texture updates
|
|
|
|
// Dimensions for a bulky CRT
|
|
const TV_WIDTH = 12;
|
|
const TV_HEIGHT = 9;
|
|
const TV_DEPTH = 10;
|
|
|
|
// Variables for textures
|
|
let staticTexture;
|
|
let videoTexture;
|
|
let isStaticActive = true; // Flag to control rendering loop
|
|
|
|
// --- Utility Functions (Kept for environment compatibility) ---
|
|
function pcmToWav(pcm16, sampleRate) { /* ... omitted for brevity ... */ }
|
|
function base64ToArrayBuffer(base64) { /* ... omitted for brevity ... */ }
|
|
|
|
// --- Core 3D Functions ---
|
|
|
|
function init() {
|
|
const container = document.getElementById('tv-container');
|
|
|
|
// 1. Scene Setup
|
|
scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x111827); // Dark background matching Tailwind bg-gray-900
|
|
|
|
// 2. Camera Setup
|
|
camera = new THREE.PerspectiveCamera(50, container.clientWidth / container.clientHeight, 0.1, 100);
|
|
camera.position.z = 25;
|
|
camera.position.y = 0;
|
|
|
|
// 3. Renderer Setup
|
|
renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
renderer.setSize(container.clientWidth, container.clientHeight);
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
|
container.appendChild(renderer.domElement);
|
|
|
|
// 4. Create TV Model
|
|
tvModel = createTV();
|
|
scene.add(tvModel);
|
|
|
|
// 5. Setup controls
|
|
setupVideoControls();
|
|
|
|
// 6. Add Lighting
|
|
addLights();
|
|
|
|
// 7. Event Listeners for Interaction and Resizing
|
|
window.addEventListener('resize', onWindowResize, false);
|
|
renderer.domElement.addEventListener('mousedown', onMouseDown, false);
|
|
renderer.domElement.addEventListener('mousemove', onMouseMove, false);
|
|
renderer.domElement.addEventListener('mouseup', onMouseUp, false);
|
|
renderer.domElement.addEventListener('touchstart', onTouchStart, false);
|
|
renderer.domElement.addEventListener('touchmove', onTouchMove, false);
|
|
renderer.domElement.addEventListener('touchend', onMouseUp, false);
|
|
|
|
// Initial view rotation
|
|
tvModel.rotation.y = Math.PI / 4;
|
|
tvModel.rotation.x = -Math.PI / 16;
|
|
}
|
|
|
|
function createTV() {
|
|
const tvGroup = new THREE.Group();
|
|
|
|
// --- 1. CRT Body Casing (Bulky Box) ---
|
|
const bodyGeometry = new THREE.BoxGeometry(TV_WIDTH, TV_HEIGHT, TV_DEPTH);
|
|
const bodyMaterial = new THREE.MeshPhongMaterial({
|
|
color: 0x554433, // Brown/Beige plastic casing
|
|
specular: 0x111111,
|
|
shininess: 10
|
|
});
|
|
const bodyMesh = new THREE.Mesh(bodyGeometry, bodyMaterial);
|
|
tvGroup.add(bodyMesh);
|
|
|
|
// --- 2. Screen (Dynamic Canvas Texture for initial static) ---
|
|
|
|
// Create the initial dynamic static canvas (512x512 resolution for texture)
|
|
const screenCanvas = document.createElement('canvas');
|
|
screenCanvas.width = 512;
|
|
screenCanvas.height = 512;
|
|
const screenContext = screenCanvas.getContext('2d');
|
|
staticTexture = new THREE.CanvasTexture(screenCanvas); // Store global reference to static texture
|
|
|
|
// Parameters for CRT screen curvature
|
|
const screenRadius = 25; // Large radius for a subtle curve
|
|
const screenWidth = TV_WIDTH - 1.5;
|
|
const screenHeight = TV_HEIGHT - 1.5;
|
|
const screenOffset = TV_DEPTH / 2 + 0.01;
|
|
|
|
// Calculate the angle needed to span the screen width on the curved surface
|
|
const screenArcAngle = screenWidth / screenRadius;
|
|
|
|
// Geometry: Segment of a cylinder to create a concave screen
|
|
const screenGeometry = new THREE.CylinderGeometry(
|
|
screenRadius, screenRadius,
|
|
screenHeight, // Cylinder height corresponds to screen height (Y axis)
|
|
32, // Radial segments (for smoothness)
|
|
1,
|
|
true, // Open ended
|
|
-screenArcAngle / 2, // Start angle
|
|
screenArcAngle // Total angle (arc length)
|
|
);
|
|
|
|
// Use a Basic Material with the static texture initially
|
|
const screenMaterial = new THREE.MeshBasicMaterial({
|
|
map: staticTexture,
|
|
// Setting DoubleSide ensures the concave side of the geometry renders.
|
|
side: THREE.DoubleSide
|
|
});
|
|
screenMesh = new THREE.Mesh(screenGeometry, screenMaterial); // Store global reference
|
|
screenMesh.userData.context = screenContext; // Store context for static update
|
|
screenMesh.name = 'crtScreen'; // Give it a name for easy lookup
|
|
|
|
// Rotation and Positioning:
|
|
// Rotate 180 degrees around Y to flip the mesh so the CONCAVE side faces the camera (+Z).
|
|
screenMesh.rotation.y = Math.PI;
|
|
|
|
// Position: Move back by the radius so the front-center aligns with the front of the TV
|
|
screenMesh.position.z = screenOffset - screenRadius;
|
|
|
|
tvGroup.add(screenMesh);
|
|
|
|
|
|
// --- 3. Knobs/Dials on the side ---
|
|
const knobGeometry = new THREE.CylinderGeometry(0.3, 0.3, 0.5, 16);
|
|
const knobMaterial = new THREE.MeshPhongMaterial({ color: 0x333333, shininess: 30 });
|
|
|
|
// Channel Knob
|
|
const channelKnob = new THREE.Mesh(knobGeometry, knobMaterial);
|
|
channelKnob.rotation.z = Math.PI / 2; // Rotate to stick out from the side
|
|
channelKnob.position.set(TV_WIDTH / 2 + 0.25, TV_HEIGHT / 2 - 2, 0);
|
|
tvGroup.add(channelKnob);
|
|
|
|
// Volume Knob
|
|
const volumeKnob = new THREE.Mesh(knobGeometry, knobMaterial);
|
|
volumeKnob.rotation.z = Math.PI / 2;
|
|
volumeKnob.position.set(TV_WIDTH / 2 + 0.25, TV_HEIGHT / 2 - 3.5, 0);
|
|
tvGroup.add(volumeKnob);
|
|
|
|
// --- 4. Antenna (Rabbit Ears) ---
|
|
const antennaMaterial = new THREE.MeshPhongMaterial({ color: 0xaaaaaa, shininess: 80 });
|
|
const antennaGeometry = new THREE.CylinderGeometry(0.05, 0.05, 5, 8);
|
|
|
|
// Base connection point
|
|
const antennaBase = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.3, 16), knobMaterial);
|
|
antennaBase.position.set(0, TV_HEIGHT / 2 + 0.15 + TV_HEIGHT / 2, 0);
|
|
tvGroup.add(antennaBase);
|
|
|
|
// Left Ear
|
|
const leftEar = new THREE.Mesh(antennaGeometry, antennaMaterial);
|
|
leftEar.rotation.z = Math.PI / 4;
|
|
leftEar.position.set(-1.5, TV_HEIGHT / 2 + 2.5 + TV_HEIGHT / 2, 0);
|
|
tvGroup.add(leftEar);
|
|
|
|
// Right Ear
|
|
const rightEar = new THREE.Mesh(antennaGeometry, antennaMaterial);
|
|
rightEar.rotation.z = -Math.PI / 4;
|
|
rightEar.position.set(1.5, TV_HEIGHT / 2 + 2.5 + TV_HEIGHT / 2, 0);
|
|
tvGroup.add(rightEar);
|
|
|
|
return tvGroup;
|
|
}
|
|
|
|
function setupVideoControls() {
|
|
const input = document.getElementById('videoFileInput');
|
|
const video = document.getElementById('videoElement');
|
|
const label = document.getElementById('loadVideoLabel');
|
|
|
|
input.addEventListener('change', (event) => {
|
|
const file = event.target.files[0];
|
|
if (file) {
|
|
label.textContent = "Loading...";
|
|
loadVideoTexture(file, video);
|
|
}
|
|
});
|
|
}
|
|
|
|
function loadVideoTexture(file, video) {
|
|
// Stop any existing video playback and revoke old URL
|
|
if (video.src) {
|
|
video.pause();
|
|
URL.revokeObjectURL(video.src);
|
|
}
|
|
|
|
// Create new blob URL for the selected file
|
|
const videoUrl = URL.createObjectURL(file);
|
|
video.src = videoUrl;
|
|
|
|
// When video metadata is loaded, create the texture and assign it
|
|
video.onloadeddata = () => {
|
|
isStaticActive = false; // Stop static rendering
|
|
|
|
// Dispose of previous video texture if it exists
|
|
if (videoTexture) {
|
|
videoTexture.dispose();
|
|
}
|
|
|
|
// Create new VideoTexture
|
|
videoTexture = new THREE.VideoTexture(video);
|
|
videoTexture.minFilter = THREE.LinearFilter;
|
|
videoTexture.magFilter = THREE.LinearFilter;
|
|
videoTexture.format = THREE.RGBAFormat;
|
|
|
|
// Update the screen mesh material map
|
|
if (screenMesh) {
|
|
screenMesh.material.map = videoTexture;
|
|
screenMesh.material.needsUpdate = true;
|
|
document.getElementById('loadVideoLabel').textContent = "Video Playing (Click to change)";
|
|
|
|
// Attempt to play the video (it must be muted/playsinline for auto-play in most browsers)
|
|
video.play().catch(e => {
|
|
console.error("Video playback failed (User interaction required):", e);
|
|
document.getElementById('loadVideoLabel').textContent = "Playback blocked (Click screen to play)";
|
|
// Handle browsers blocking auto-play by waiting for user interaction
|
|
renderer.domElement.addEventListener('click', () => {
|
|
video.play().then(() => {
|
|
document.getElementById('loadVideoLabel').textContent = "Video Playing (Click to change)";
|
|
}).catch(err => console.error("Manual playback failed:", err));
|
|
}, { once: true });
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
function updateStaticTexture() {
|
|
if (!screenMesh || !isStaticActive) return;
|
|
|
|
const screenContext = screenMesh.userData.context;
|
|
const screenCanvas = staticTexture.image;
|
|
|
|
const w = screenCanvas.width;
|
|
const h = screenCanvas.height;
|
|
const imageData = screenContext.getImageData(0, 0, w, h);
|
|
const data = imageData.data;
|
|
const numPixels = w * h;
|
|
|
|
// Time-based shift for "color bloom/misalignment" effect
|
|
const time = Date.now() * 0.005;
|
|
const rShift = Math.sin(time * 0.5) * 5;
|
|
const gShift = Math.sin(time * 0.5 + 2) * 5;
|
|
const bShift = Math.sin(time * 0.5 + 4) * 5;
|
|
|
|
// Generate random noise
|
|
for (let i = 0; i < numPixels; i++) {
|
|
const noise = Math.random() * 200 + 55;
|
|
const dataIndex = i * 4;
|
|
|
|
// Apply color shift
|
|
data[dataIndex] = Math.min(255, noise + rShift + Math.random() * 10);
|
|
data[dataIndex + 1] = Math.min(255, noise + gShift + Math.random() * 10);
|
|
data[dataIndex + 2] = Math.min(255, noise + bShift + Math.random() * 10);
|
|
data[dataIndex + 3] = 255; // Alpha
|
|
}
|
|
|
|
screenContext.putImageData(imageData, 0, 0);
|
|
staticTexture.needsUpdate = true; // Update the static texture
|
|
}
|
|
|
|
function addLights() {
|
|
// Ambient light for general scene illumination
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
|
scene.add(ambientLight);
|
|
|
|
// Directional light from the front-top-right
|
|
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
directionalLight1.position.set(5, 10, 10);
|
|
scene.add(directionalLight1);
|
|
}
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
|
|
if (isStaticActive) {
|
|
updateStaticTexture();
|
|
} else if (videoTexture) {
|
|
// Ensure video texture updates when playing
|
|
videoTexture.needsUpdate = true;
|
|
}
|
|
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
// --- Event Handlers (Drag/Resize Logic) ---
|
|
|
|
function onWindowResize() {
|
|
const container = document.getElementById('tv-container');
|
|
camera.aspect = container.clientWidth / container.clientHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(container.clientWidth, container.clientHeight);
|
|
}
|
|
|
|
function onMouseDown(event) {
|
|
isDragging = true;
|
|
previousMousePosition.x = event.clientX;
|
|
previousMousePosition.y = event.clientY;
|
|
}
|
|
|
|
function onTouchStart(event) {
|
|
if (event.touches.length === 1) {
|
|
isDragging = true;
|
|
previousMousePosition.x = event.touches[0].clientX;
|
|
previousMousePosition.y = event.touches[0].clientY;
|
|
}
|
|
}
|
|
|
|
function onMouseMove(event) {
|
|
if (!isDragging) return;
|
|
|
|
const deltaX = event.clientX - previousMousePosition.x;
|
|
const deltaY = event.clientY - previousMousePosition.y;
|
|
|
|
const rotationSpeed = 0.005;
|
|
|
|
tvModel.rotation.y += deltaX * rotationSpeed;
|
|
tvModel.rotation.x += deltaY * rotationSpeed;
|
|
tvModel.rotation.x = Math.max(-Math.PI / 4, Math.min(Math.PI / 4, tvModel.rotation.x));
|
|
|
|
|
|
previousMousePosition.x = event.clientX;
|
|
previousMousePosition.y = event.clientY;
|
|
}
|
|
|
|
function onTouchMove(event) {
|
|
if (!isDragging || event.touches.length !== 1) return;
|
|
|
|
const touch = event.touches[0];
|
|
const deltaX = touch.clientX - previousMousePosition.x;
|
|
const deltaY = touch.clientY - previousMousePosition.y;
|
|
|
|
const rotationSpeed = 0.007;
|
|
|
|
tvModel.rotation.y += deltaX * rotationSpeed;
|
|
tvModel.rotation.x += deltaY * rotationSpeed;
|
|
tvModel.rotation.x = Math.max(-Math.PI / 4, Math.min(Math.PI / 4, tvModel.rotation.x));
|
|
|
|
previousMousePosition.x = touch.clientX;
|
|
previousMousePosition.y = touch.clientY;
|
|
event.preventDefault();
|
|
}
|
|
|
|
|
|
function onMouseUp(event) {
|
|
isDragging = false;
|
|
}
|
|
|
|
|
|
// Start the application when the window loads
|
|
window.onload = function () {
|
|
init();
|
|
animate();
|
|
};
|
|
|
|
</script>
|
|
</body>
|
|
</html> |