One-time play through and VCR timing
This commit is contained in:
parent
c870b7e5f3
commit
4784d5ee26
@ -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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Status message area --><p id="status" class="text-sm text-yellow-300 text-center font-mono opacity-80">Ready.</p>
|
||||
</div>
|
||||
|
||||
<!-- 3D Canvas will be injected here by Three.js --><script>
|
||||
@ -77,9 +71,7 @@
|
||||
const container = document.body;
|
||||
const videoElement = document.getElementById('video');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const statusText = document.getElementById('status');
|
||||
const loadTapeButton = document.getElementById('loadTapeButton');
|
||||
const nextTapeButton = document.getElementById('nextTapeButton');
|
||||
const loader = new THREE.TextureLoader();
|
||||
|
||||
const debugLight = false;
|
||||
@ -181,7 +173,6 @@
|
||||
loadTapeButton.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
nextTapeButton.addEventListener('click', playNextVideo);
|
||||
|
||||
// Auto-advance to the next video when the current one finishes.
|
||||
videoElement.addEventListener('ended', playNextVideo);
|
||||
@ -576,6 +567,12 @@
|
||||
|
||||
// Position the curved screen
|
||||
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.position.set(x, 0, z);
|
||||
@ -592,6 +589,11 @@
|
||||
screenLight.shadow.camera.far = 5;
|
||||
tvGroup.add(screenLight);
|
||||
|
||||
// -- VCR --
|
||||
const vcr = createVcr();
|
||||
vcr.position.set(-0.3, 0.6, 0.05);
|
||||
tvGroup.add(vcr);
|
||||
|
||||
scene.add(tvGroup);
|
||||
}
|
||||
|
||||
@ -748,33 +750,8 @@
|
||||
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 ---
|
||||
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;
|
||||
const url = videoUrls[index];
|
||||
|
||||
@ -784,12 +761,27 @@
|
||||
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.muted = true;
|
||||
videoElement.load();
|
||||
|
||||
// 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 = () => {
|
||||
@ -811,29 +803,29 @@
|
||||
// Use the defined base intensity for screen glow
|
||||
screenLight.intensity = originalScreenIntensity;
|
||||
// Initial status message with tape count
|
||||
statusText.textContent = `Playing tape ${currentVideoIndex + 1} of ${videoUrls.length}.`;
|
||||
updateControls();
|
||||
console.info(`Playing tape ${currentVideoIndex + 1} of ${videoUrls.length}.`);
|
||||
}).catch(error => {
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
videoElement.onerror = (e) => {
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
// --- Cycle to the next video ---
|
||||
function playNextVideo() {
|
||||
if (videoUrls.length > 0) {
|
||||
// Determine the next index, cycling back to 0 if we reach the end
|
||||
let nextIndex = (currentVideoIndex + 1) % videoUrls.length;
|
||||
playVideoByIndex(nextIndex);
|
||||
let nextIndex = currentVideoIndex + 1;
|
||||
if (nextIndex < videoUrls.length) {
|
||||
baseTime += videoElement.duration;
|
||||
}
|
||||
playVideoByIndex(nextIndex);
|
||||
}
|
||||
|
||||
|
||||
@ -841,7 +833,7 @@
|
||||
function loadVideoFile(event) {
|
||||
const files = event.target.files;
|
||||
if (files.length === 0) {
|
||||
statusText.textContent = 'File selection cancelled.';
|
||||
console.info('File selection cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -858,14 +850,300 @@
|
||||
}
|
||||
|
||||
if (videoUrls.length === 0) {
|
||||
statusText.textContent = 'No valid video files selected.';
|
||||
updateControls();
|
||||
console.info('No valid video files selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Start playback of the first video
|
||||
statusText.textContent = `Loaded ${videoUrls.length} tapes. Starting playback...`;
|
||||
playVideoByIndex(0);
|
||||
console.info(`Loaded ${videoUrls.length} tapes. Starting playback...`);
|
||||
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() {
|
||||
@ -1140,13 +1418,26 @@
|
||||
if (isVideoLoaded && videoElement.readyState >= 3) {
|
||||
const currentTime = formatTime(videoElement.currentTime);
|
||||
const duration = formatTime(videoElement.duration);
|
||||
statusText.textContent =
|
||||
`Tape ${currentVideoIndex + 1} of ${videoUrls.length}. Time: ${currentTime} / ${duration}`;
|
||||
console.info(`Tape ${currentVideoIndex + 1} of ${videoUrls.length}. Time: ${currentTime} / ${duration}`);
|
||||
}
|
||||
}
|
||||
|
||||
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!
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user