One-time play through and VCR timing

This commit is contained in:
Dejvino 2025-11-08 23:56:40 +01:00
parent c870b7e5f3
commit 4784d5ee26

View File

@ -51,13 +51,7 @@
<!-- Load Tapes Button --><button id="loadTapeButton" class="tape-button px-8 py-3 bg-tape-red text-white font-bold text-lg uppercase tracking-wider rounded-lg hover:bg-red-700 transition duration-150"> <!-- Load Tapes Button --><button id="loadTapeButton" class="tape-button px-8 py-3 bg-tape-red text-white font-bold text-lg uppercase tracking-wider rounded-lg hover:bg-red-700 transition duration-150">
Load tapes Load tapes
</button> </button>
<!-- Next Tape Button (still allows manual skip) --><button id="nextTapeButton" class="tape-button px-6 py-3 bg-gray-600 text-white font-bold text-lg uppercase tracking-wider rounded-lg opacity-50 cursor-not-allowed" disabled>
Next (0/0)
</button>
</div> </div>
<!-- Status message area --><p id="status" class="text-sm text-yellow-300 text-center font-mono opacity-80">Ready.</p>
</div> </div>
<!-- 3D Canvas will be injected here by Three.js --><script> <!-- 3D Canvas will be injected here by Three.js --><script>
@ -77,9 +71,7 @@
const container = document.body; const container = document.body;
const videoElement = document.getElementById('video'); const videoElement = document.getElementById('video');
const fileInput = document.getElementById('fileInput'); const fileInput = document.getElementById('fileInput');
const statusText = document.getElementById('status');
const loadTapeButton = document.getElementById('loadTapeButton'); const loadTapeButton = document.getElementById('loadTapeButton');
const nextTapeButton = document.getElementById('nextTapeButton');
const loader = new THREE.TextureLoader(); const loader = new THREE.TextureLoader();
const debugLight = false; const debugLight = false;
@ -181,7 +173,6 @@
loadTapeButton.addEventListener('click', () => { loadTapeButton.addEventListener('click', () => {
fileInput.click(); fileInput.click();
}); });
nextTapeButton.addEventListener('click', playNextVideo);
// Auto-advance to the next video when the current one finishes. // Auto-advance to the next video when the current one finishes.
videoElement.addEventListener('ended', playNextVideo); videoElement.addEventListener('ended', playNextVideo);
@ -576,6 +567,12 @@
// Position the curved screen // Position the curved screen
tvScreen.position.set(0.0, 1.5, -2.1); tvScreen.position.set(0.0, 1.5, -2.1);
tvScreen.material = new THREE.MeshPhongMaterial({
color: 0x0a0a0a, // Deep black
shininess: 5,
specular: 0x111111
});
tvScreen.material.needsUpdate = true;
tvGroup.add(tvScreen); tvGroup.add(tvScreen);
tvGroup.position.set(x, 0, z); tvGroup.position.set(x, 0, z);
@ -592,6 +589,11 @@
screenLight.shadow.camera.far = 5; screenLight.shadow.camera.far = 5;
tvGroup.add(screenLight); tvGroup.add(screenLight);
// -- VCR --
const vcr = createVcr();
vcr.position.set(-0.3, 0.6, 0.05);
tvGroup.add(vcr);
scene.add(tvGroup); scene.add(tvGroup);
} }
@ -748,33 +750,8 @@
return `${paddedMinutes}:${paddedSeconds}`; return `${paddedMinutes}:${paddedSeconds}`;
} }
// --- Helper function to update the control buttons' state and text ---
function updateControls() {
const total = videoUrls.length;
const current = currentVideoIndex + 1;
if (total > 1) {
nextTapeButton.disabled = false;
nextTapeButton.classList.remove('opacity-50', 'cursor-not-allowed', 'bg-gray-600');
nextTapeButton.classList.add('bg-tape-red', 'hover:bg-red-700');
} else {
nextTapeButton.disabled = true;
nextTapeButton.classList.add('opacity-50', 'cursor-not-allowed', 'bg-gray-600');
nextTapeButton.classList.remove('bg-tape-red', 'hover:bg-red-700');
}
// Always show the current tape count
nextTapeButton.textContent = `Next (${current}/${total})`;
}
// --- Play video by index --- // --- Play video by index ---
function playVideoByIndex(index) { function playVideoByIndex(index) {
if (index < 0 || index >= videoUrls.length) {
statusText.textContent = 'End of playlist reached. Reload tapes to start again.';
screenLight.intensity = 0.0;
return;
}
currentVideoIndex = index; currentVideoIndex = index;
const url = videoUrls[index]; const url = videoUrls[index];
@ -784,12 +761,27 @@
videoTexture = null; videoTexture = null;
} }
if (index < 0 || index >= videoUrls.length) {
console.info('End of playlist reached. Reload tapes to start again.');
screenLight.intensity = 0.0;
tvScreen.material.dispose();
tvScreen.material = new THREE.MeshPhongMaterial({
color: 0x0a0a0a, // Deep black
shininess: 5,
specular: 0x111111
});
tvScreen.material.needsUpdate = true;
isVideoLoaded = false;
lastUpdateTime = -1; // force VCR to redraw
return;
}
videoElement.src = url; videoElement.src = url;
videoElement.muted = true; videoElement.muted = true;
videoElement.load(); videoElement.load();
// Set loop property: only loop if it's the only video loaded // Set loop property: only loop if it's the only video loaded
videoElement.loop = videoUrls.length === 1; videoElement.loop = false; //videoUrls.length === 1;
videoElement.onloadeddata = () => { videoElement.onloadeddata = () => {
@ -811,29 +803,29 @@
// Use the defined base intensity for screen glow // Use the defined base intensity for screen glow
screenLight.intensity = originalScreenIntensity; screenLight.intensity = originalScreenIntensity;
// Initial status message with tape count // Initial status message with tape count
statusText.textContent = `Playing tape ${currentVideoIndex + 1} of ${videoUrls.length}.`; console.info(`Playing tape ${currentVideoIndex + 1} of ${videoUrls.length}.`);
updateControls();
}).catch(error => { }).catch(error => {
screenLight.intensity = originalScreenIntensity * 0.5; // Dim the light if playback fails screenLight.intensity = originalScreenIntensity * 0.5; // Dim the light if playback fails
statusText.textContent = `Playback blocked for tape ${currentVideoIndex + 1}. Click Next Tape to try again.`; console.error(`Playback blocked for tape ${currentVideoIndex + 1}. Click Next Tape to try again.`);
console.error('Playback Error: Could not start video playback.', error); console.error('Playback Error: Could not start video playback.', error);
}); });
}; };
videoElement.onerror = (e) => { videoElement.onerror = (e) => {
screenLight.intensity = 0.1; // Keep minimum intensity for shadow map screenLight.intensity = 0.1; // Keep minimum intensity for shadow map
statusText.textContent = `Error loading tape ${currentVideoIndex + 1}.`; console.error(`Error loading tape ${currentVideoIndex + 1}.`);
console.error('Video Load Error:', e); console.error('Video Load Error:', e);
}; };
} }
// --- Cycle to the next video --- // --- Cycle to the next video ---
function playNextVideo() { function playNextVideo() {
if (videoUrls.length > 0) {
// Determine the next index, cycling back to 0 if we reach the end // Determine the next index, cycling back to 0 if we reach the end
let nextIndex = (currentVideoIndex + 1) % videoUrls.length; let nextIndex = currentVideoIndex + 1;
playVideoByIndex(nextIndex); if (nextIndex < videoUrls.length) {
baseTime += videoElement.duration;
} }
playVideoByIndex(nextIndex);
} }
@ -841,7 +833,7 @@
function loadVideoFile(event) { function loadVideoFile(event) {
const files = event.target.files; const files = event.target.files;
if (files.length === 0) { if (files.length === 0) {
statusText.textContent = 'File selection cancelled.'; console.info('File selection cancelled.');
return; return;
} }
@ -858,14 +850,300 @@
} }
if (videoUrls.length === 0) { if (videoUrls.length === 0) {
statusText.textContent = 'No valid video files selected.'; console.info('No valid video files selected.');
updateControls();
return; return;
} }
// 3. Start playback of the first video // 3. Start playback of the first video
statusText.textContent = `Loaded ${videoUrls.length} tapes. Starting playback...`; console.info(`Loaded ${videoUrls.length} tapes. Starting playback...`);
playVideoByIndex(0); loadTapeButton.classList.add("hidden");
const startDelay = 5;
console.info(`Video will start in ${startDelay} seconds.`);
setTimeout(() => { playVideoByIndex(0); }, startDelay * 1000);
}
let vcrDisplayLight;
let simulatedPlaybackTime = 0;
let lastUpdateTime = -1;
let baseTime = 0;
let blinkState = false; // For blinking colon
let lastBlinkToggleTime = 0;
// --- Segment Display Definitions ---
// Define which segments (indexed 0-6: A, B, C, D, E, F, G) are active for each digit
// A=Top, B=TR, C=BR, D=Bottom, E=BL, F=TL, G=Middle
const SEGMENTS = {
'0': [1, 1, 1, 1, 1, 1, 0],
'1': [0, 1, 1, 0, 0, 0, 0],
'2': [1, 1, 0, 1, 1, 0, 1],
'3': [1, 1, 1, 1, 0, 0, 1],
'4': [0, 1, 1, 0, 0, 1, 1],
'5': [1, 0, 1, 1, 0, 1, 1],
'6': [1, 0, 1, 1, 1, 1, 1],
'7': [1, 1, 1, 0, 0, 0, 0],
'8': [1, 1, 1, 1, 1, 1, 1],
'9': [1, 1, 1, 1, 0, 1, 1],
' ': [0, 0, 0, 0, 0, 0, 0]
};
const SEG_THICKNESS = 3; // Thickness of the segment line in canvas pixels
const SEG_PADDING = 2; // Padding within a digit segment's box
// Colors for active and inactive segments
const COLOR_ACTIVE = '#00ff44'; // Bright Fluorescent Green
const COLOR_INACTIVE = '#1a1a1a'; // Dim dark gray for 'ghost' segments
/**
* Draws a single 7-segment digit by drawing active segments.
* Now includes drawing of inactive (ghost) segments for better readability.
* @param {CanvasRenderingContext2D} ctx
* @param {string} digit The digit character (0-9).
* @param {number} x Left position of the digit area.
* @param {number} y Top position of the digit area.
* @param {number} H Total height of the digit area.
*/
function drawSegmentDigit(ctx, digit, x, y, H) {
const segments = SEGMENTS[digit] || SEGMENTS[' '];
const W = H / 2; // Width is half the height for standard aspect ratio
// Segment dimensions relative to W and H
const hLength = W - 2 * SEG_PADDING;
// Vertical length calculation: (Total height - 2 paddings - 3 horizontal thicknesses) / 2
const vLength = (H - (2 * SEG_PADDING) - (3 * SEG_THICKNESS)) / 2;
// Helper to draw horizontal segment (A, G, D)
const drawH = (index, x_start, y_start) => {
ctx.fillStyle = segments[index] ? COLOR_ACTIVE : COLOR_INACTIVE;
ctx.fillRect(x_start + SEG_PADDING, y_start, hLength, SEG_THICKNESS);
};
// Helper to draw vertical segment (F, B, E, C)
const drawV = (index, x_start, y_start) => {
ctx.fillStyle = segments[index] ? COLOR_ACTIVE : COLOR_INACTIVE;
ctx.fillRect(x_start, y_start, SEG_THICKNESS, vLength);
};
// Define segment positions
// Horizontal segments
// A (Top) - index 0
drawH(0, x, y + SEG_PADDING);
// G (Middle) - index 6
drawH(6, x, y + H/2 - SEG_THICKNESS/2);
// D (Bottom) - index 3
drawH(3, x, y + H - SEG_PADDING - SEG_THICKNESS);
// Vertical segments (Top Half)
const topVStart = y + SEG_PADDING + SEG_THICKNESS;
const rightVStart = x + W - SEG_PADDING - SEG_THICKNESS;
// F (Top-Left) - index 5
drawV(5, x + SEG_PADDING, topVStart);
// B (Top-Right) - index 1
drawV(1, rightVStart, topVStart);
// Vertical segments (Bottom Half)
const bottomVStart = y + H/2 + SEG_THICKNESS/2;
// E (Bottom-Left) - index 4
drawV(4, x + SEG_PADDING, bottomVStart);
// C (Bottom-Right) - index 2
drawV(2, rightVStart, bottomVStart);
}
// Function to draw the colon (two dots), now with blinking logic
function drawColon(ctx, x, y, H, isVisible) {
const dotSize = 4;
ctx.fillStyle = COLOR_ACTIVE;
if (isVisible) {
// Top dot
ctx.fillRect(x, y + H * 0.3 - dotSize / 2, dotSize, dotSize);
// Bottom dot
ctx.fillRect(x, y + H * 0.7 - dotSize / 2, dotSize, dotSize);
} else {
// Draw inactive colon if not visible, for consistency
ctx.fillStyle = COLOR_INACTIVE;
ctx.fillRect(x, y + H * 0.3 - dotSize / 2, dotSize, dotSize);
ctx.fillRect(x, y + H * 0.7 - dotSize / 2, dotSize, dotSize);
}
}
/**
* Draws a simple playback arrow (triangle)
* @param {CanvasRenderingContext2D} ctx
* @param {number} x Left position of the arrow area.
* @param {number} y Top position of the arrow area.
* @param {number} H Total height of the arrow area.
*/
function drawPlaybackArrow(ctx, x, y, H) {
const arrowWidth = H * 0.4; // Arrow width relative to digit height
const arrowHeight = H * 0.4; // Arrow height relative to digit height
ctx.fillStyle = COLOR_ACTIVE;
ctx.beginPath();
ctx.moveTo(x, y + H * 0.5 - arrowHeight / 2); // Top point
ctx.lineTo(x + arrowWidth, y + H * 0.5); // Right point (center)
ctx.lineTo(x, y + H * 0.5 + arrowHeight / 2); // Bottom point
ctx.closePath();
ctx.fill();
}
// Main function to render the entire time string using segments
function drawSegmentDisplay(ctx, timeString) {
const canvasWidth = ctx.canvas.width;
const canvasHeight = ctx.canvas.height;
const timeStringLength = timeString.length;
// Clear display to dark background
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// Constants for layout
const charSpacing = 8; // Spacing between digits
const digitHeight = canvasHeight - 2 * SEG_PADDING;
const digitWidth = digitHeight / 2 + SEG_PADDING; // Total width slot for one digit
const colonWidth = 6;
const arrowWidth = digitHeight * 0.7; // Approx width for the arrow
const arrowPadding = 10; // Space between arrow and first digit
// Calculate total display width including arrow and spaces
const totalDisplayWidth = arrowWidth + arrowPadding + (4 * digitWidth) + colonWidth + ((timeStringLength - 1) * charSpacing);
// Calculate starting X to center the display
let currentX = (canvasWidth - totalDisplayWidth) / 2;
const currentY = SEG_PADDING;
// Draw Playback Arrow
if (isVideoLoaded && videoElement.readyState >= 3) {
drawPlaybackArrow(ctx, currentX, currentY, digitHeight);
}
currentX += arrowWidth + arrowPadding; // Move X after arrow and its padding
for (let i = 0; i < timeStringLength; i++) {
const char = timeString[i];
if (char === ':') {
drawColon(ctx, currentX, currentY, digitHeight, blinkState); // Pass blinkState
currentX += colonWidth;
} else if (char >= '0' && char <= '9') {
drawSegmentDigit(ctx, char, currentX, currentY, digitHeight);
currentX += digitWidth;
}
// Add spacing only if it's not the last element
if (i < timeStringLength - 1) {
currentX += charSpacing;
}
}
}
// --- VCR Display Functions ---
function createVcrDisplay() {
const canvas = document.createElement('canvas');
canvas.width = 160; // Increased width for arrow and better spacing
canvas.height = 32;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
vcrDisplayTexture = new THREE.CanvasTexture(canvas);
vcrDisplayTexture.needsUpdate = true;
const displayGeometry = new THREE.PlaneGeometry(0.45, 0.1); // Adjust geometry width for new canvas size
const displayMaterial = new THREE.MeshBasicMaterial({
map: vcrDisplayTexture,
side: THREE.FrontSide,
color: 0xffffff,
transparent: true,
emissive: 0x00ff44,
emissiveIntensity: 0.1
});
const displayMesh = new THREE.Mesh(displayGeometry, displayMaterial);
return displayMesh;
}
function updateVcrDisplay(time) {
if (!vcrDisplayTexture) return;
const canvas = vcrDisplayTexture.image;
const ctx = canvas.getContext('2d');
const timeString = formatTime(time);
// Uses the new segment drawing function with ghosting, including blinkState for colon
drawSegmentDisplay(ctx, timeString);
vcrDisplayTexture.needsUpdate = true;
}
// --- VCR Model Function ---
function createVcr() {
// Materials
const vcrBodyMaterial = new THREE.MeshPhongMaterial({
color: 0x222222, // Dark metallic gray
shininess: 70,
specular: 0x444444
});
const slotMaterial = new THREE.MeshPhongMaterial({
color: 0x0a0a0a, // Deep black
shininess: 5,
specular: 0x111111
});
const floorMaterial = new THREE.MeshPhongMaterial({ color: 0x1a1a1a, shininess: 5 });
// VCR Body
const vcrBodyGeometry = new THREE.BoxGeometry(1.0, 0.2, 0.7);
const vcrBody = new THREE.Mesh(vcrBodyGeometry, vcrBodyMaterial);
vcrBody.position.y = 0; // Centered
vcrBody.castShadow = true;
vcrBody.receiveShadow = true;
// Cassette Slot / Front Face
const slotGeometry = new THREE.BoxGeometry(0.9, 0.05, 0.01);
const slotMesh = new THREE.Mesh(slotGeometry, slotMaterial);
slotMesh.position.set(0, -0.05, 0.35 + 0.005);
slotMesh.castShadow = true;
slotMesh.receiveShadow = true;
// VCR Display
const displayMesh = createVcrDisplay();
displayMesh.position.z = 0.35 + 0.005;
displayMesh.position.x = 0.2; // Adjusted X for arrow
displayMesh.position.y = 0.03;
// VCR Group
const vcrGroup = new THREE.Group();
vcrGroup.add(vcrBody, slotMesh, displayMesh);
vcrGroup.position.set(0, 0.1, 0); // Position the whole VCR slightly above the floor
// Light from the VCR display itself
vcrDisplayLight = new THREE.PointLight(0x00ff44, 0.5, 1);
vcrDisplayLight.position.set(0.3, 0.03, 0.35 + 0.05); // Move light slightly closer to VCR surface
vcrDisplayLight.castShadow = true;
vcrDisplayLight.shadow.mapSize.width = 256;
vcrDisplayLight.shadow.mapSize.height = 256;
vcrGroup.add(vcrDisplayLight);
return vcrGroup;
}
// --- Helper function to format seconds into MM:SS ---
function formatTime(seconds) {
if (isNaN(seconds) || seconds < 0) return '00:00';
const totalSeconds = Math.floor(seconds);
const minutes = Math.floor(totalSeconds / 60);
const remainingSeconds = totalSeconds % 60;
const paddedMinutes = String(minutes).padStart(2, '0');
const paddedSeconds = String(remainingSeconds).padStart(2, '0');
return `${paddedMinutes}:${paddedSeconds}`;
} }
function randomFlyTarget() { function randomFlyTarget() {
@ -1140,13 +1418,26 @@
if (isVideoLoaded && videoElement.readyState >= 3) { if (isVideoLoaded && videoElement.readyState >= 3) {
const currentTime = formatTime(videoElement.currentTime); const currentTime = formatTime(videoElement.currentTime);
const duration = formatTime(videoElement.duration); const duration = formatTime(videoElement.duration);
statusText.textContent = console.info(`Tape ${currentVideoIndex + 1} of ${videoUrls.length}. Time: ${currentTime} / ${duration}`);
`Tape ${currentVideoIndex + 1} of ${videoUrls.length}. Time: ${currentTime} / ${duration}`;
} }
} }
updateFlies(); updateFlies();
const currentTime = baseTime + videoElement.currentTime;
// Simulate playback time
if (Math.abs(currentTime - lastUpdateTime) > 0.1) {
updateVcrDisplay(currentTime);
lastUpdateTime = currentTime;
}
// Blink the colon every second
if (currentTime - lastBlinkToggleTime > 0.5) { // Blink every 0.5 seconds
blinkState = !blinkState;
lastBlinkToggleTime = currentTime;
}
// RENDER! // RENDER!
renderer.render(scene, camera); renderer.render(scene, camera);
} }